From 6354c1245187311e759d3d9ab5148a5f91a5fdef Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Wed, 8 Jul 2026 13:04:49 -0400 Subject: [PATCH 1/5] =?UTF-8?q?fix(desktop):=20r=C3=A9ordonne=20les=20ongl?= =?UTF-8?q?ets=20via=20=C3=A9v=C3=A9nements=20pointeur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le drag-and-drop HTML5 natif est peu fiable dans les webviews WebKit qui font tourner l'app Tauri : dragstart ne se déclenche souvent pas. On pilote le réordonnancement avec des événements souris à la place. 🪄 Commit via GitWand --- .../src/components/header/RepoTabStrip.vue | 139 +++++++++++++----- 1 file changed, 104 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/components/header/RepoTabStrip.vue b/apps/desktop/src/components/header/RepoTabStrip.vue index d3cab305..7e6f07a1 100644 --- a/apps/desktop/src/components/header/RepoTabStrip.vue +++ b/apps/desktop/src/components/header/RepoTabStrip.vue @@ -69,38 +69,102 @@ 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. We drive it with plain mouse events instead, which +// behave identically across every webview. draggedIndex / hoveredIndex keep +// the same meaning the CSS classes expect (source index / hovered target). const draggedIndex = ref(null); const hoveredIndex = ref(null); - -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()); +const stripEl = ref(null); +// Live horizontal offset of the tab being dragged, so it tracks the cursor. +const dragOffsetX = ref(0); + +// A press only becomes a drag past this threshold, so a plain click still +// switches tabs instead of being swallowed as a (zero-distance) drag. +const DRAG_THRESHOLD_PX = 4; +let pressIndex: number | null = null; +let pressStartX = 0; +let didDrag = false; +// 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) => { + const r = el.getBoundingClientRect(); + return r.left + r.width / 2; + }) + : []; +} + +/** Index of the tab slot under `clientX`, from the drag-start snapshot. */ +function tabIndexAtX(clientX: number): number | null { + if (tabCenters.length === 0) return null; + for (let i = 0; i < tabCenters.length; i++) { + if (clientX < tabCenters[i]) return i; } + return tabCenters.length - 1; } -function onDragOver(e: DragEvent, index: number) { - e.preventDefault(); // Required to allow drop - if (draggedIndex.value !== null && draggedIndex.value !== index) { - hoveredIndex.value = index; +function onTabMouseDown(e: MouseEvent, index: number, tabId: number) { + if (e.button === 1) { + // Middle-click closes, same as before. + e.preventDefault(); + emit("closeTab", tabId); + return; } -} - -function onDrop(e: DragEvent, index: number) { + if (e.button !== 0) return; + // Don't start a drag from the caret or close affordances. + if ((e.target as Element).closest?.(".repo-tab__caret, .repo-tab__close")) return; + // Stop the browser from starting a text selection on the press — the click + // still fires, so tab switching is unaffected. e.preventDefault(); - if (draggedIndex.value !== null && draggedIndex.value !== index) { - emit("reorderTabs", draggedIndex.value, index); + pressIndex = index; + pressStartX = e.clientX; + didDrag = false; + window.addEventListener("mousemove", onDragMove); + window.addEventListener("mouseup", onDragEnd); +} + +function onDragMove(e: MouseEvent) { + if (pressIndex === null) return; + if (!didDrag) { + if (Math.abs(e.clientX - pressStartX) < DRAG_THRESHOLD_PX) return; + didDrag = true; + draggedIndex.value = pressIndex; + snapshotCenters(); } - draggedIndex.value = null; - hoveredIndex.value = null; + // Suppress the text selection that a mouse-drag would otherwise paint over + // the tab labels and neighbouring header content. + e.preventDefault(); + dragOffsetX.value = e.clientX - pressStartX; + hoveredIndex.value = tabIndexAtX(e.clientX); } function onDragEnd() { + window.removeEventListener("mousemove", onDragMove); + window.removeEventListener("mouseup", onDragEnd); + if ( + 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 = []; + // Leave `didDrag` set so the click that follows a drag is suppressed; + // onTabClick resets it. } // Pinned and recent repos shown in the + dropdown (excludes repos already @@ -212,6 +276,9 @@ onUnmounted(() => { document.removeEventListener("keydown", onDocumentKey); window.removeEventListener("resize", onWindowChange); window.removeEventListener("scroll", onWindowChange, true); + // Guard against unmounting mid-drag leaking the window listeners. + window.removeEventListener("mousemove", onDragMove); + window.removeEventListener("mouseup", onDragEnd); }); /** @@ -300,6 +367,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 +379,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 +386,11 @@ function onCloseClick(e: MouseEvent, tabId: number) {