Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
"version": "0.28.1",
"version": "0.28.2",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
15 changes: 6 additions & 9 deletions src/renderer/src/components/TabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from
import { ContextMenu } from './ContextMenu'
import { MenuIcon } from './MenuIcon'
import { useTabWidth } from '../lib/tabWidthPrefs'
import { dropSlot, type Lane } from '../lib/tabDrop'

/**
* The open shells, as a row under the title bar.
Expand Down Expand Up @@ -182,7 +183,7 @@ export function TabStrip({
// sits under the pointer cannot work once they animate: the neighbour
// slides out from under the cursor, the answer flips back, and the strip
// judders. Frozen geometry has no feedback loop.
const lanes = useRef<Array<{ left: number; width: number; mid: number }>>([])
const lanes = useRef<Lane[]>([])
const strip = useRef<HTMLDivElement>(null)
const startX = useRef(0)
/** True once the press has travelled far enough to BE a drag: a plain click
Expand All @@ -199,11 +200,6 @@ export function TabStrip({
heldFocus.current = null
if (el && document.contains(el)) requestAnimationFrame(() => el.focus())
}
/** The slot a point is asking for, from the frozen lanes. */
const slotAt = (x: number): number => {
const at = lanes.current.findIndex((l) => x < l.mid)
return at === -1 ? lanes.current.length : at
}
const onTabPointerDown = (e: PointerEvent<HTMLDivElement>, id: string, i: number): void => {
if (e.button !== 0) return
// The X is not a handle: capturing the pointer here would swallow its
Expand Down Expand Up @@ -234,9 +230,10 @@ export function TabStrip({
? Math.max(box.left - lane.left, Math.min(raw, box.right - (lane.left + lane.width)))
: raw
setCarry((c) => (c ? { ...c, dx, live: true } : c))
// The CARRIED tab's own centre decides, not the pointer: it is what the
// eye is following, and it keeps a grab near an edge honest.
if (lane) setDropAt(slotAt(lane.mid + dx))
// The CARRIED tab decides, not the pointer: it is what the eye is
// following, and it keeps a grab near an edge honest. Its leading edge,
// so first place is in reach (#125, lib/tabDrop).
setDropAt(dropSlot(lanes.current, carry.from, dx))
}
const onTabPointerUp = (e: PointerEvent<HTMLDivElement>): void => {
if (!carry) return
Expand Down
46 changes: 46 additions & 0 deletions src/renderer/src/lib/tabDrop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { dropSlot, type Lane } from './tabDrop'

const lanes = (...widths: number[]): Lane[] => {
let x = 0
return widths.map((width) => {
const lane = { left: x, width, mid: x + width / 2 }
x += width
return lane
})
}

describe('dropSlot', () => {
it('stays put without a move', () => {
expect(dropSlot(lanes(100, 100, 100), 1, 0)).toBe(1)
})

it('reaches first place when the carried tab is wider than the first (#125)', () => {
// Prism 90 wide, Github 120: clamped at the strip's edge, dx = -90.
expect(dropSlot(lanes(90, 120, 100), 1, -90)).toBe(0)
})

it('reaches first place with equal widths at the clamp', () => {
expect(dropSlot(lanes(100, 100, 100), 1, -100)).toBe(0)
})

it('passes a lane going left once the left edge is past its middle', () => {
expect(dropSlot(lanes(100, 100, 100), 2, -49)).toBe(2)
expect(dropSlot(lanes(100, 100, 100), 2, -51)).toBe(1)
expect(dropSlot(lanes(100, 100, 100), 2, -151)).toBe(0)
})

it('passes a lane going right once the right edge is past its middle, as reorderTabs counts', () => {
expect(dropSlot(lanes(100, 100, 100), 0, 49)).toBe(0)
expect(dropSlot(lanes(100, 100, 100), 0, 51)).toBe(2)
expect(dropSlot(lanes(100, 100, 100), 0, 151)).toBe(3)
})

it('reaches last place when the carried tab is wider than the last', () => {
expect(dropSlot(lanes(120, 100, 90), 0, 190)).toBe(3)
})

it('answers the slot it started in for an unknown lane', () => {
expect(dropSlot([], 0, 40)).toBe(0)
})
})
30 changes: 30 additions & 0 deletions src/renderer/src/lib/tabDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** One tab's place in the strip, frozen when a drag starts. */
export interface Lane {
left: number
width: number
mid: number
}

/**
* The slot a carried tab asks for, in `reorderTabs`' terms (the index in the
* list as it was, so passing lane j going right is j + 1).
*
* The LEADING EDGE decides, not the tab's centre (#125; owner, 2026-10-04: "i
* cant drag that github tab to the left of the prism tab"). The tab is clamped
* to the strip, and a centre had to pass the first tab's centre: a tab as wide
* as the first or wider could never get there, so first place was out of
* reach. An edge reaches the strip's own edge, which is past every centre.
* Going left the carried tab's left edge must pass a lane's middle, going
* right its right edge must.
*/
export function dropSlot(lanes: readonly Lane[], from: number, dx: number): number {
const lane = lanes[from]
if (!lane) return from
const left = lane.left + dx
const right = left + lane.width
let slot = from
for (let i = from - 1; i >= 0 && left < lanes[i].mid; i -= 1) slot = i
if (slot !== from) return slot
for (let i = from + 1; i < lanes.length && right > lanes[i].mid; i += 1) slot = i + 1
return slot
}
34 changes: 34 additions & 0 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,40 @@ const scenarios = {
* measured is each tab's box, not the class that sets it, because a class
* that loses to a longer label is exactly what this replaced.
*/
// A TAB REACHES FIRST PLACE (#125; owner, 2026-10-04: "i cant drag that
// github tab to the left of the prism tab to make it first"). The second
// tab is the WIDER one (Dynamic, a longer name), which is what could never
// get there: its centre was clamped short of the first tab's centre.
async tabDragFirst(ok) {
const w = world()
const wide = join(w.alpha, '..', 'a-much-longer-folder-name')
mkdirSync(wide)
const { app, page } = await launch(w, { args: [w.alpha, wide] })
ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open')
const boxes = await page.locator('[data-tab]').evaluateAll((els) => els.map((el) => el.getBoundingClientRect().toJSON()))
ok(boxes[1].width > boxes[0].width, `the second tab is the wider (${Math.round(boxes[1].width)} > ${Math.round(boxes[0].width)})`)
const y = boxes[1].top + boxes[1].height / 2
await page.mouse.move(boxes[1].left + boxes[1].width / 2, y)
await page.mouse.down()
for (let x = boxes[1].left + boxes[1].width / 2; x > 0; x -= 20) await page.mouse.move(x, y)
await page.mouse.move(0, y)
await page.mouse.up()
ok(
await until(async () => (await tabLabels(page))[0]?.includes('a-much-longer-folder-name')),
`dragged all the way left it is first (${JSON.stringify(await tabLabels(page))})`
)
const back = await page.locator('[data-tab]').evaluateAll((els) => els.map((el) => el.getBoundingClientRect().toJSON()))
await page.mouse.move(back[0].left + back[0].width / 2, y)
await page.mouse.down()
for (let x = back[0].left + back[0].width / 2; x < back[1].right + 40; x += 20) await page.mouse.move(x, y)
await page.mouse.up()
ok(
await until(async () => (await tabLabels(page))[1]?.includes('a-much-longer-folder-name')),
`and dragged right it is last again (${JSON.stringify(await tabLabels(page))})`
)
await closeApp(app)
},

async tabWidth(ok) {
const w = world()
const long = join(w.alpha, '..', 'a-folder-with-a-name-far-too-long-to-fit-on-any-tab')
Expand Down
Loading