diff --git a/package-lock.json b/package-lock.json index 582e39e..d39ab20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.28.1", + "version": "0.28.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.28.1", + "version": "0.28.2", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 3fff1a1..a09d008 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index 83df395..82baa95 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -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. @@ -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>([]) + const lanes = useRef([]) const strip = useRef(null) const startX = useRef(0) /** True once the press has travelled far enough to BE a drag: a plain click @@ -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, id: string, i: number): void => { if (e.button !== 0) return // The X is not a handle: capturing the pointer here would swallow its @@ -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): void => { if (!carry) return diff --git a/src/renderer/src/lib/tabDrop.test.ts b/src/renderer/src/lib/tabDrop.test.ts new file mode 100644 index 0000000..0bec6c2 --- /dev/null +++ b/src/renderer/src/lib/tabDrop.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/tabDrop.ts b/src/renderer/src/lib/tabDrop.ts new file mode 100644 index 0000000..5a31845 --- /dev/null +++ b/src/renderer/src/lib/tabDrop.ts @@ -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 +} diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index e529ace..e501fd7 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -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')