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
This commit is contained in:
2026-09-11 14:10:05 +02:00
parent eb4346fce2
commit 54972f24b8
2 changed files with 92 additions and 4 deletions
+14
View File
@@ -2799,6 +2799,19 @@
return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path)); 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() { function closeRepoTabContextMenu() {
repoTabContextMenu = null; repoTabContextMenu = null;
} }
@@ -5405,6 +5418,7 @@
onOpenIssues={() => { activeView = "issues"; }} onOpenIssues={() => { activeView = "issues"; }}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab} onSelect={selectRepoTab}
onReorder={reorderRepoTab}
onClose={closeRepoTab} onClose={closeRepoTab}
onContextMenu={openRepoTabContextMenu} onContextMenu={openRepoTabContextMenu}
onAdd={chooseRepositoryFolder} onAdd={chooseRepositoryFolder}
+78 -4
View File
@@ -20,8 +20,67 @@
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {}; export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {}; export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
export let onAdd: () => void | Promise<void> = () => {}; export let onAdd: () => void | Promise<void> = () => {};
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<HTMLElement>(".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;
}
</script> </script>
<svelte:window onpointermove={moveDrag} onpointerup={(event) => { if (event.pointerId === drag?.pointerId) finishDrag(true); }} onpointercancel={() => finishDrag(false)} onblur={() => finishDrag(false)} onkeydown={(event) => { if (event.key === "Escape") finishDrag(false); }}/>
<header class="workspace-navigation"> <header class="workspace-navigation">
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}> <nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button> <button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
@@ -30,10 +89,15 @@
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues &amp; Boards</span></button> <button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues &amp; Boards</span></button>
</nav> </nav>
{#if activeView === "repository"} {#if activeView === "repository"}
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}> <nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
{#each repoTabs as repo (repo.path)} {#each repoTabs as repo, index (repo.path)}
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}> <div class="repository-tab" class:active={isActive(repo.path)}
<button class="repository-select" type="button" onclick={() => onSelect(repo.path)} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button> class:dragging={dragging && drag?.path === repo.path}
style:transform={`translateX(${tabOffset(index, drag, dragging, offset)}px)`}
role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
<button class="repository-select" type="button" onpointerdown={(event) => startDrag(event, repo.path)}
onlostpointercapture={() => { if (drag) finishDrag(false); }}
onclick={(event) => { if (suppressClick) { event.preventDefault(); suppressClick = false; return; } onSelect(repo.path); }} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
<button class="repository-close" type="button" onclick={(event) => onClose(repo.path, event)} disabled={isBusy} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}><X size={13}/></button> <button class="repository-close" type="button" onclick={(event) => onClose(repo.path, event)} disabled={isBusy} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}><X size={13}/></button>
</div> </div>
{/each} {/each}
@@ -57,6 +121,16 @@
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)} .repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)} .repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
.repository-tab:hover{background:var(--color-surface-hover)} .repository-tab:hover{background:var(--color-surface-hover)}
.repository-tab{position:relative;will-change:transform}
/* Animate the preview only. On drop, the new DOM order replaces the
transforms in the same render, so resetting them must not animate. */
.reordering .repository-tab{transition:transform 160ms ease}
.reordering .repository-tab.dragging{z-index:2;transition:none;background:var(--color-surface-raised);border-color:var(--color-accent);box-shadow:0 2px 12px #0005}
.repository-navigation.reordering,.reordering .repository-select{cursor:grabbing}
.repository-select{touch-action:pan-y;user-select:none}
@media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition:none}}
.repository-select:not(:disabled){cursor:grab}
.reordering .repository-select:not(:disabled){cursor:grabbing}
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left} .repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left}
.repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.repository-select>:global(svg){flex-shrink:0} .repository-select>:global(svg){flex-shrink:0}