diff --git a/apps/desktop/src/components/__tests__/RepoTabStrip.test.ts b/apps/desktop/src/components/__tests__/RepoTabStrip.test.ts new file mode 100644 index 00000000..82e8c107 --- /dev/null +++ b/apps/desktop/src/components/__tests__/RepoTabStrip.test.ts @@ -0,0 +1,285 @@ +/** + * RepoTabStrip.vue — pointer-based drag-to-reorder regression guards. + * + * Native HTML5 drag-and-drop is unreliable in the WebKit webviews that power + * the packaged Tauri app, so tab reordering is driven by Pointer Events + * instead (see the "Drag to reorder" comment block in the component) — one + * code path for mouse, touch and pen. That hand-rolled state machine has + * sharp edges a component test can pin down without a real browser: a click + * near a tab's own edge must not itself register as a reorder, a drag must + * only ever be committed by the button that started it, and a re-snapshot + * mid-drag (scroll, resize) must not double-count the dragged tab's own live + * offset. + * + * jsdom has no PointerEvent constructor and no setPointerCapture, so drags + * are driven with plain `Event` objects carrying the same fields the + * component reads off a real PointerEvent (clientX/clientY/button/pointerId) + * — dispatchEvent doesn't care which Event subclass a listener was declared + * against, only the `type` string. The component itself feature-detects + * setPointerCapture, so it degrades to window-listener-only tracking under + * jsdom exactly like it would in a webview that lacks pointer capture. + * + * Mounted with native `createApp` into jsdom (no @vue/test-utils dep), + * mirroring MergeEditor.test / LlmTracePanel.test / LaunchpadView.test. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { createApp, h, nextTick, type App } from "vue"; +import RepoTabStrip from "../header/RepoTabStrip.vue"; +import type { RepoTab } from "../../composables/useRepoTabs"; + +function makeTabs(n: number): RepoTab[] { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + path: `/repo-${i}`, + name: `repo-${i}`, + })); +} + +let app: App | null = null; +let container: HTMLElement | null = null; + +afterEach(() => { + app?.unmount(); + app = null; + container?.remove(); + container = null; +}); + +interface Mounted { + tabEls: HTMLElement[]; + reorders: Array<[number, number]>; +} + +function mount(tabs: RepoTab[]): Mounted { + container = document.createElement("div"); + document.body.appendChild(container); + const reorders: Array<[number, number]> = []; + + app = createApp({ + render() { + return h(RepoTabStrip, { + tabs, + activeTabId: tabs[0]?.id ?? null, + onReorderTabs: (oldIndex: number, newIndex: number) => { + reorders.push([oldIndex, newIndex]); + }, + }); + }, + }); + app.mount(container); + + const tabEls = Array.from(container.querySelectorAll(".repo-tab")); + return { tabEls, reorders }; +} + +/** Stubs the geometry each tab needs for the drag hit-test to run in jsdom. */ +function stubRects(tabEls: HTMLElement[], widths: number[], lefts?: number[]) { + let left = 0; + for (let i = 0; i < tabEls.length; i++) { + const width = widths[i]; + const l = lefts ? lefts[i] : left; + const rect = { left: l, right: l + width, top: 0, bottom: 30, width, height: 30 } as DOMRect; + tabEls[i].getBoundingClientRect = () => rect; + left += width; + } +} + +let nextPointerId = 1; + +function firePointer( + target: EventTarget, + type: string, + opts: { clientX?: number; clientY?: number; button?: number; pointerId?: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clientX", { get: () => opts.clientX ?? 0 }); + Object.defineProperty(event, "clientY", { get: () => opts.clientY ?? 15 }); + Object.defineProperty(event, "button", { get: () => opts.button ?? 0 }); + Object.defineProperty(event, "pointerId", { get: () => opts.pointerId ?? nextPointerId }); + target.dispatchEvent(event); +} + +describe("RepoTabStrip — drag to reorder", () => { + it("a few pixels of wobble on a tab's right half does not reorder it", async () => { + // Regression: the hit-test used to compare the raw cursor position + // against each tab's center, so pressing the right half of a wide tab + // put the cursor on the far side of its own center *before any + // movement at all* — a subsequent few-pixel wobble (well under what + // anyone would call a drag) was enough to swap it with its neighbour. + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + // Press near the right edge of tab 0 (center is at x=50). + firePointer(tabEls[0], "pointerdown", { clientX: 90 }); + // Wobble 5px right — just past the 4px drag threshold. + firePointer(window, "pointermove", { clientX: 95 }); + firePointer(window, "pointerup", { clientX: 95 }); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("dragging a tab past its neighbour's center reorders it", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + // Press at the center of tab 0 and drag well past tab 1's center (150). + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); + firePointer(window, "pointermove", { clientX: 160 }); + firePointer(window, "pointerup", { clientX: 160 }); + await nextTick(); + + expect(reorders).toEqual([[0, 1]]); + }); + + it("a right-click released mid-drag does not commit or cancel it", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + firePointer(tabEls[0], "pointerdown", { clientX: 50, button: 0 }); + firePointer(window, "pointermove", { clientX: 160 }); + // A right-button pointerup mid-drag must be ignored... + firePointer(window, "pointerup", { clientX: 160, button: 2 }); + await nextTick(); + expect(reorders).toEqual([]); + // ...the left-button release still ends the drag normally. + firePointer(window, "pointerup", { clientX: 160, button: 0 }); + await nextTick(); + expect(reorders).toEqual([[0, 1]]); + }); + + it("releasing far outside the strip cancels the drag instead of reordering", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); + // Drag past tab 1's center but with the cursor far below the strip. + firePointer(window, "pointermove", { clientX: 160, clientY: 500 }); + firePointer(window, "pointerup", { clientX: 160, clientY: 500 }); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("a window blur mid-drag cancels it", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); + firePointer(window, "pointermove", { clientX: 160 }); + window.dispatchEvent(new Event("blur")); + // The pointerup that follows (returning from another app) must not commit. + firePointer(window, "pointerup", { clientX: 160 }); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("a pointercancel mid-drag (e.g. a touch gesture interrupted) cancels it", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + stubRects(tabEls, [100, 100, 100]); + + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); + firePointer(window, "pointermove", { clientX: 160 }); + firePointer(window, "pointercancel", { clientX: 160 }); + firePointer(window, "pointerup", { clientX: 160 }); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("a resize mid-drag re-snapshots without double-counting the dragged tab's own offset", async () => { + // Regression: re-snapshotting centers mid-drag (on scroll or resize) used + // to re-measure the dragged tab's rect as-is — but that rect already + // includes the live translateX a real browser would have painted, so the + // offset got added to the hit-test a second time and the drop landed one + // slot further than it should have. + const { tabEls, reorders } = mount(makeTabs(3)); + // Resting centers: 50, 150, 250 (widths 100 each, lefts 0/100/200). + stubRects(tabEls, [100, 100, 100]); + + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); + // Drag 100px right — didDrag flips true and snapshots the resting + // centers; hoveredIndex is still 0 (hasn't crossed tab 1's center yet). + firePointer(window, "pointermove", { clientX: 150 }); + + // Simulate the DOM now reflecting that 100px live translateX: tab 0's + // rect has visually shifted right by 100, exactly as a real browser + // would render it mid-drag (the other tabs don't move). + stubRects(tabEls, [100, 100, 100], [100, 100, 200]); + window.dispatchEvent(new Event("resize")); + + // Move on to a total offset of 110px from the press point — enough to + // cross tab 1's resting center (150) but not tab 2's (250) once the + // resize re-snapshot has correctly stripped the baked-in offset back out. + firePointer(window, "pointermove", { clientX: 160 }); + firePointer(window, "pointerup", { clientX: 160 }); + await nextTick(); + + expect(reorders).toEqual([[0, 1]]); + }); +}); + +describe("RepoTabStrip — keyboard reordering", () => { + function fireCtrlShiftArrow(target: EventTarget, key: "ArrowLeft" | "ArrowRight") { + target.dispatchEvent( + new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }), + ); + } + + it("Ctrl+Shift+ArrowRight moves the focused tab one slot right and announces it", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + + fireCtrlShiftArrow(tabEls[0], "ArrowRight"); + await nextTick(); + + expect(reorders).toEqual([[0, 1]]); + expect(container?.querySelector('[role="status"]')?.textContent).toBe("repo-0 moved to position 2 of 3"); + }); + + it("Ctrl+Shift+ArrowLeft moves the focused tab one slot left", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + + fireCtrlShiftArrow(tabEls[1], "ArrowLeft"); + await nextTick(); + + expect(reorders).toEqual([[1, 0]]); + }); + + it("does nothing at the boundary (first tab can't move left, last can't move right)", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + + fireCtrlShiftArrow(tabEls[0], "ArrowLeft"); + fireCtrlShiftArrow(tabEls[2], "ArrowRight"); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("a plain ArrowRight (no modifier) does not reorder — only switches focus natively", async () => { + const { tabEls, reorders } = mount(makeTabs(3)); + + tabEls[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true })); + await nextTick(); + + expect(reorders).toEqual([]); + }); + + it("bare Ctrl+ArrowRight (no Shift) does not reorder", async () => { + // Regression: a bare Ctrl+Arrow is macOS's default Mission Control + // "switch Space" shortcut, intercepted by the OS before it would even + // reach the app — Shift is required precisely to stay clear of that (and + // of third-party window-manager shortcuts using the same bare combo). + // This only guards our own handler's condition; it can't simulate the + // OS swallowing the event outright. + const { tabEls, reorders } = mount(makeTabs(3)); + + tabEls[0].dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, bubbles: true, cancelable: true }), + ); + await nextTick(); + + expect(reorders).toEqual([]); + }); +}); diff --git a/apps/desktop/src/components/header/RepoTabStrip.vue b/apps/desktop/src/components/header/RepoTabStrip.vue index d3cab305..c365fca7 100644 --- a/apps/desktop/src/components/header/RepoTabStrip.vue +++ b/apps/desktop/src/components/header/RepoTabStrip.vue @@ -69,38 +69,249 @@ function isScratch(path: string): boolean { return base.startsWith("gitwand-scratch-"); } -// ─── Drag and Drop (v2.15) ────────────────────────────── +// ─── Drag to reorder — pointer-based ───────────────────── +// Native HTML5 drag-and-drop is unreliable in WebKit (WKWebView on macOS, +// WebKitGTK on Linux — both power the packaged Tauri app): `dragstart` +// frequently never fires, so the tabs couldn't be reordered and no drop +// indicator showed. Pointer events drive it instead — one code path for +// mouse, touch and pen — and the gesture is claimed via setPointerCapture so +// a native touch-scroll can't steal it mid-drag. draggedIndex / hoveredIndex +// keep the same meaning the CSS classes expect (source index / hovered +// target). const draggedIndex = ref(null); const hoveredIndex = ref(null); +const stripEl = ref(null); +// Live horizontal offset of the tab being dragged, so it tracks the cursor. +const dragOffsetX = ref(0); +/** Rendered by the aria-live region — feedback for a keyboard reorder. */ +const announceText = ref(""); + +// A press only becomes a drag past this threshold, so a plain click/tap +// still switches tabs instead of being swallowed as a (zero-distance) drag. +const DRAG_THRESHOLD_PX = 4; +// How far above/below the strip the cursor can stray before a release reads +// as "dropped outside" (cancelled) rather than a reorder. +const DRAG_VERTICAL_TOLERANCE_PX = 40; +let pressIndex: number | null = null; +let pressStartX = 0; +let didDrag = false; +// The element/pointer a drag claimed via setPointerCapture, so it can be +// released again in finishDrag. Capture is best-effort: some webviews don't +// implement it, so every use is feature-detected and wrapped in try/catch. +let capturedEl: HTMLElement | null = null; +let capturedPointerId: number | null = null; +// Center-x of each tab captured when the drag begins. The dragged tab gets a +// translateX to follow the cursor, which would move its own bounding rect — so +// we hit-test against this snapshot (the other tabs don't move until drop). +let tabCenters: number[] = []; + +function snapshotCenters() { + const root = stripEl.value; + tabCenters = root + ? Array.from(root.querySelectorAll(".repo-tab")).map((el, i) => { + const r = el.getBoundingClientRect(); + const center = r.left + r.width / 2; + // The dragged tab renders with a live translateX so it visually + // tracks the pointer; a re-snapshot mid-drag (scroll, resize) would + // otherwise measure that offset baked into its rect and then add + // dragOffsetX on top of it a second time in indexForDragOffset — + // strip it back out so this always holds each tab's *resting* + // position, exactly like the very first snapshot (taken at offset 0, + // before any transform is rendered). + return i === draggedIndex.value ? center - dragOffsetX.value : center; + }) + : []; +} -function onDragStart(e: DragEvent, index: number) { - draggedIndex.value = index; - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = "move"; - // Required for some browsers to initiate drag - e.dataTransfer.setData("text/plain", index.toString()); +/** + * Index of the slot the dragged tab's own (shifted) center now sits in, + * i.e. how many of the *other* tabs' (unmoving) centers it has been dragged + * past. Hit-testing against the dragged tab's own current center — its + * drag-start center plus the live offset — rather than the raw cursor + * position means a press anywhere within the tab (left half or right half) + * starts at offset 0, so a few pixels of hand wobble can't immediately swap + * it with a neighbour just because the press happened to land past its + * midpoint. Excluding the dragged tab's own original center from the count + * matters too: comparing against the full list would have that slot count + * against itself at offset 0, overshooting straight to `draggedIndex + 1` + * before any real movement happened. + */ +function indexForDragOffset(offsetX: number): number | null { + if (tabCenters.length === 0 || draggedIndex.value === null) return null; + const draggedCenter = tabCenters[draggedIndex.value] + offsetX; + let index = 0; + for (let i = 0; i < tabCenters.length; i++) { + if (i === draggedIndex.value) continue; + if (tabCenters[i] < draggedCenter) index++; } + return index; } -function onDragOver(e: DragEvent, index: number) { - e.preventDefault(); // Required to allow drop - if (draggedIndex.value !== null && draggedIndex.value !== index) { - hoveredIndex.value = index; - } +function onStripScroll() { + // Centers are snapshotted in viewport coordinates; scrolling the strip + // (it's overflow-x: auto) mid-drag shifts every tab, so re-snapshot to + // keep the hit-test honest. + if (didDrag) snapshotCenters(); } -function onDrop(e: DragEvent, index: number) { +function onTabPointerDown(e: PointerEvent, index: number, tabId: number) { + if (e.button === 1) { + // Middle-click closes, same as before. + e.preventDefault(); + emit("closeTab", tabId); + return; + } + if (e.button !== 0) return; + // Don't start a drag from the close affordance. (The caret already stops + // propagation on its own @pointerdown, so it never reaches this handler.) + if ((e.target as Element).closest?.(".repo-tab__close")) return; + pressIndex = index; + pressStartX = e.clientX; + didDrag = false; + capturedEl = e.currentTarget as HTMLElement; + capturedPointerId = e.pointerId; + window.addEventListener("pointermove", onDragMove); + window.addEventListener("pointerup", onDragEnd); + window.addEventListener("pointercancel", onDragCancel); + window.addEventListener("blur", onDragCancel); +} + +function onDragMove(e: PointerEvent) { + if (pressIndex === null) return; + if (!didDrag) { + if (Math.abs(e.clientX - pressStartX) < DRAG_THRESHOLD_PX) return; + didDrag = true; + draggedIndex.value = pressIndex; + snapshotCenters(); + // Claim the gesture so pointermove/pointerup keep targeting this tab + // even once the pointer leaves it — guards against a native touch-pan + // gesture on the strip's own overflow-x scroll winning the race and + // hijacking the drag partway through. + if (capturedEl && typeof capturedEl.setPointerCapture === "function") { + try { + capturedEl.setPointerCapture(e.pointerId); + } catch { + // Best-effort — the window listeners still drive the drag without it. + } + } + } + // Suppress the text selection / touch-scroll a drag would otherwise paint + // over the tab labels and neighbouring header content. e.preventDefault(); - if (draggedIndex.value !== null && draggedIndex.value !== index) { - emit("reorderTabs", draggedIndex.value, index); + dragOffsetX.value = e.clientX - pressStartX; + const stripRect = stripEl.value?.getBoundingClientRect(); + const droppedOutside = + !!stripRect && + (e.clientY < stripRect.top - DRAG_VERTICAL_TOLERANCE_PX || + e.clientY > stripRect.bottom + DRAG_VERTICAL_TOLERANCE_PX); + // Cursor well above/below the strip (over app content, a native title bar, + // etc.) reads as "no drop target" — mirrors the old native DnD, where + // dragging outside any drop zone left the tab order untouched. + hoveredIndex.value = droppedOutside ? null : indexForDragOffset(dragOffsetX.value); +} + +/** Detaches the drag's window listeners and resets its transient state. */ +function finishDrag(commit: boolean) { + window.removeEventListener("pointermove", onDragMove); + window.removeEventListener("pointerup", onDragEnd); + window.removeEventListener("pointercancel", onDragCancel); + window.removeEventListener("blur", onDragCancel); + if (capturedEl && capturedPointerId !== null && typeof capturedEl.releasePointerCapture === "function") { + try { + capturedEl.releasePointerCapture(capturedPointerId); + } catch { + // Already released (e.g. the browser dropped it and fired + // pointercancel first) — nothing left to release. + } + } + capturedEl = null; + capturedPointerId = null; + if ( + commit && + didDrag && + draggedIndex.value !== null && + hoveredIndex.value !== null && + hoveredIndex.value !== draggedIndex.value + ) { + emit("reorderTabs", draggedIndex.value, hoveredIndex.value); } + pressIndex = null; draggedIndex.value = null; hoveredIndex.value = null; -} + dragOffsetX.value = 0; + tabCenters = []; + // The click that follows a pointerup on the same element fires + // synchronously in the same task, before this timeout runs — so it's still + // suppressed below. Resetting on a deferred tick, rather than relying on + // that click reaching onTabClick, means the flag can't get stuck when the + // release lands off the dragged tab, which would otherwise swallow the + // very next keyboard tab activation. + setTimeout(() => { + didDrag = false; + }, 0); +} + +function onDragEnd(e: PointerEvent) { + // Only the button that can start a drag can end it — a right/middle-click + // released mid-drag (e.g. after a middle-click closed a different tab) + // must not commit or abort this one. Touch/pen contacts report button 0, + // same as a left click, so this doesn't affect them. + if (e.button !== 0) return; + finishDrag(true); +} + +function onDragCancel() { + finishDrag(false); +} + +// A tab opening/closing mid-drag (deep link, programmatic close, …) leaves +// draggedIndex/hoveredIndex and the center snapshot pointing at stale +// positions — abort rather than risk committing a reorder against them. +watch( + () => props.tabs.length, + () => { + if (pressIndex !== null) onDragCancel(); + }, +); -function onDragEnd() { - draggedIndex.value = null; - hoveredIndex.value = null; +// ─── Keyboard reordering ───────────────────────────────── +// The pointer/touch drag above has no keyboard equivalent, which would +// otherwise leave reordering unreachable without a mouse, trackpad or +// touchscreen. Ctrl/Cmd+Shift+ArrowLeft/Right nudges the focused tab one slot +// at a time; the aria-live region above announces the result since the +// reorder is a silent DOM move with nothing else for a screen reader to key +// off of. +// +// Bare Ctrl/Cmd+ArrowLeft/Right was tried first and rejected: on macOS, +// Ctrl+Arrow is the *default* Mission Control "switch Space" shortcut, +// handled by the OS before the keydown ever reaches any app — including a +// browser, so it's not a Tauri-specific gap. Cmd+Arrow isn't bound by +// default, but plenty of window-manager utilities (Rectangle, yabai, …) and +// user remaps claim bare Cmd/Ctrl+Arrow too. Requiring Shift as well avoids +// every OS-level and third-party shortcut we know of for this combo — the +// same reasoning VS Code's editor-move commands are bound to Shift-modified +// combos rather than a bare arrow. +async function moveFocusedTab(index: number, tab: RepoTab, direction: -1 | 1) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= props.tabs.length) return; + emit("reorderTabs", index, newIndex); + announceText.value = t("header.tabStripReorderAnnounce", tab.name, newIndex + 1, props.tabs.length); + // `tabs` is keyed by `tab.id`, so Vue moves the same DOM node rather than + // recreating it and focus should already follow — but re-focus explicitly + // by id once the reorder lands, in case the parent re-renders the list. + await nextTick(); + stripEl.value?.querySelector(`[data-tab-id="${tab.id}"]`)?.focus(); +} + +function onTabKeydown(e: KeyboardEvent, index: number, tab: RepoTab) { + if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; + if (e.key === "ArrowLeft") { + e.preventDefault(); + moveFocusedTab(index, tab, -1); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + moveFocusedTab(index, tab, 1); + } } // Pinned and recent repos shown in the + dropdown (excludes repos already @@ -188,6 +399,7 @@ function onDocumentKey(e: KeyboardEvent) { if (e.key !== "Escape") return; if (showMenu.value) closeMenu(); if (wtMenuTabId.value !== null) closeWorktreeMenu(); + if (pressIndex !== null) onDragCancel(); } // Reposition when the menu opens — and close on resize / strip scroll @@ -199,6 +411,10 @@ watch(showMenu, (open) => { function onWindowChange() { if (showMenu.value) closeMenu(); if (wtMenuTabId.value !== null) closeWorktreeMenu(); + // A window resize (or a zoom-level change) shifts every tab's viewport + // position exactly like a strip scroll would — re-snapshot so the + // hit-test in onDragMove keeps hit-testing against live centers. + if (didDrag) snapshotCenters(); } onMounted(() => { @@ -206,12 +422,19 @@ onMounted(() => { document.addEventListener("keydown", onDocumentKey); window.addEventListener("resize", onWindowChange); window.addEventListener("scroll", onWindowChange, true); + stripEl.value?.addEventListener("scroll", onStripScroll, { passive: true }); }); onUnmounted(() => { document.removeEventListener("mousedown", onDocumentClick); document.removeEventListener("keydown", onDocumentKey); window.removeEventListener("resize", onWindowChange); window.removeEventListener("scroll", onWindowChange, true); + stripEl.value?.removeEventListener("scroll", onStripScroll); + // Guard against unmounting mid-drag leaking the window listeners. + window.removeEventListener("pointermove", onDragMove); + window.removeEventListener("pointerup", onDragEnd); + window.removeEventListener("pointercancel", onDragCancel); + window.removeEventListener("blur", onDragCancel); }); /** @@ -300,6 +523,11 @@ function removeWorktree(w: WorktreeEntry) { * caret for the same action. */ function onTabClick(e: MouseEvent, tab: RepoTab) { + // A drag just ended — swallow the trailing click so it doesn't switch tabs. + if (didDrag) { + didDrag = false; + return; + } if (tab.id === props.activeTabId) { if (hasWorktrees(tab.path)) toggleWorktreeMenu(e, tab); return; @@ -307,13 +535,6 @@ function onTabClick(e: MouseEvent, tab: RepoTab) { emit("switchTab", tab.id); } -function onMiddleClick(e: MouseEvent, tabId: number) { - if (e.button === 1) { - e.preventDefault(); - emit("closeTab", tabId); - } -} - function onCloseClick(e: MouseEvent, tabId: number) { e.stopPropagation(); emit("closeTab", tabId); @@ -321,9 +542,21 @@ function onCloseClick(e: MouseEvent, tabId: number) {