From 54972f24b8e04beac8e1f839f3ed785e6e220713 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 11 Sep 2026 14:10:05 +0200 Subject: [PATCH] feat(repo-tabs): add drag-and-drop reordering for repo tabs Add pointer-driven drag-and-drop reordering for repository tabs. A drag state tracks source and target, supports auto-scrolling, and shows a live animated preview while dragging; accidental clicks are suppressed. Reorders commit on pointerup and can be cancelled via Escape, blur, or pointercancel, and reordering is disabled while the app is busy. The new order is persisted through a callback to the main app. - Implement pointer handlers, drag lifecycle, and click suppression - Add transform-based preview, transitional styling, and reduced-motion support - Wire reorder callback to persist the updated tab order --- src/App.svelte | 14 +++++++ src/lib/RepoTabs.svelte | 82 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 067eef3..a7fd05f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2799,6 +2799,19 @@ return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path)); } + function reorderRepoTab(path: string, targetPath: string, after: boolean) { + if (isBusy || sameRepoPath(path, targetPath)) return; + const sourceIndex = repoTabIndex(path); + if (sourceIndex < 0 || repoTabIndex(targetPath) < 0) return; + const reordered = [...repoTabs]; + const [moved] = reordered.splice(sourceIndex, 1); + const targetIndex = reordered.findIndex(tab => sameRepoPath(tab.path, targetPath)); + reordered.splice(targetIndex + (after ? 1 : 0), 0, moved); + repoTabs = reordered; + closeRepoTabContextMenu(); + persistRepoLists(); + } + function closeRepoTabContextMenu() { repoTabContextMenu = null; } @@ -5405,6 +5418,7 @@ onOpenIssues={() => { activeView = "issues"; }} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} onSelect={selectRepoTab} + onReorder={reorderRepoTab} onClose={closeRepoTab} onContextMenu={openRepoTabContextMenu} onAdd={chooseRepositoryFolder} diff --git a/src/lib/RepoTabs.svelte b/src/lib/RepoTabs.svelte index 2f1351b..3674d20 100644 --- a/src/lib/RepoTabs.svelte +++ b/src/lib/RepoTabs.svelte @@ -20,8 +20,67 @@ export let onClose: (path: string, event: MouseEvent) => void | Promise = () => {}; export let onContextMenu: (path: string, event: MouseEvent) => void = () => {}; export let onAdd: () => void | Promise = () => {}; + export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {}; + + let navigation: HTMLElement; + let drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null; + let dragging = false; + let offset = 0; + let suppressClick = false; + + function startDrag(event: PointerEvent, path: string) { + if (isBusy || event.button !== 0 || !event.isPrimary) return; + const element = event.currentTarget as HTMLElement; + const tabs = Array.from(navigation.querySelectorAll(".repository-tab")); + const source = repoTabs.findIndex(tab => tab.path === path); + drag = { path, pointerId: event.pointerId, startX: event.clientX, + startScroll: navigation.scrollLeft, source, target: source, + width: tabs[source].getBoundingClientRect().width + 4, + centers: tabs.map(tab => { const rect = tab.getBoundingClientRect(); return rect.left + rect.width / 2; }), element }; + suppressClick = false; + element.setPointerCapture(event.pointerId); + } + + function moveDrag(event: PointerEvent) { + if (!drag || event.pointerId !== drag.pointerId) return; + if (isBusy) { finishDrag(false); return; } + if (!dragging && Math.abs(event.clientX - drag.startX) < 5) return; + dragging = true; + suppressClick = true; + const bounds = navigation.getBoundingClientRect(); + if (event.clientX < bounds.left + 35) navigation.scrollLeft -= 15; + if (event.clientX > bounds.right - 35) navigation.scrollLeft += 15; + offset = event.clientX - drag.startX + navigation.scrollLeft - drag.startScroll; + const center = drag.centers[drag.source] + offset; + let target = drag.source; + while (target < drag.centers.length - 1 && center > drag.centers[target + 1]) target++; + while (target > 0 && center < drag.centers[target - 1]) target--; + drag = { ...drag, target }; + } + + function finishDrag(commit: boolean) { + const current = drag; + if (!current) return; + drag = null; + if (current.element.hasPointerCapture(current.pointerId)) current.element.releasePointerCapture(current.pointerId); + if (commit && dragging && !isBusy && current.target !== current.source) { + onReorder(current.path, repoTabs[current.target].path, current.target > current.source); + } + dragging = false; + offset = 0; + } + + function tabOffset(index: number, current: typeof drag, moving: boolean, displacement: number) { + if (!current || !moving) return 0; + if (index === current.source) return displacement; + if (current.source < index && index <= current.target) return -current.width; + if (current.target <= index && index < current.source) return current.width; + return 0; + } + { if (event.pointerId === drag?.pointerId) finishDrag(true); }} onpointercancel={() => finishDrag(false)} onblur={() => finishDrag(false)} onkeydown={(event) => { if (event.key === "Escape") finishDrag(false); }}/> +
{#if activeView === "repository"} -