Add keyboard shortcuts (Ctrl/Cmd + 1–4) to jump to the four main views: Dashboard, Repositories, Pull Requests and Issues & Boards. - Handler runs in the capture phase so editors/dialogs can still trap keys. - Switching is blocked when the app is busy or any modal/overlay is open; repeats and combinations with Alt/Shift are ignored. - When switching to the repository view the shortcut selects an existing repo tab (it will not open a native file dialog if no repo is available). Also update tab button titles to surface the shortcuts and add entries to the help overlay (English and German).
157 lines
11 KiB
Svelte
157 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
|
|
|
|
import SelectMenu from "./components/SelectMenu.svelte";
|
|
|
|
interface RepositoryTabItem {
|
|
path: string;
|
|
name: string;
|
|
branch: string | null;
|
|
}
|
|
|
|
export let activeView: "management" | "review-center" | "issues" | "repository" = "management";
|
|
export let repoTabs: RepositoryTabItem[] = [];
|
|
export let isBusy: boolean = false;
|
|
export let language: "en" | "de" = "en";
|
|
export let onOpenManagement: () => void = () => {};
|
|
export let onOpenRepositories: () => void | Promise<void> = () => {};
|
|
export let onOpenIssues: () => void = () => {};
|
|
export let onOpenReviewCenter: () => void = () => {};
|
|
export let isActive: (path: string) => boolean = () => false;
|
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
|
export let onAdd: () => void | Promise<void> = () => {};
|
|
export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {};
|
|
|
|
export let workspaceId = "";
|
|
export let workspaceOptions: { value: string; label: string }[] = [];
|
|
export let onWorkspaceChange: (id: string) => unknown = () => {};
|
|
|
|
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>
|
|
|
|
<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">
|
|
<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} title="Dashboard (Ctrl + 1)"><House size={17}/><span>Dashboard</span></button>
|
|
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories} title="Repositories (Ctrl + 2)"><Database size={17}/><span>Repositories</span></button>
|
|
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter} title="Pull Requests (Ctrl + 3)"><GitPullRequest size={17}/><span>Pull Requests</span></button>
|
|
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues} title="Issues & Boards (Ctrl + 4)"><Columns3 size={17}/><span>Issues & Boards</span></button>
|
|
</nav>
|
|
{#if activeView === "repository"}
|
|
<div class="repository-row">
|
|
<nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
|
|
{#each repoTabs as repo, index (repo.path)}
|
|
<div class="repository-tab" class:active={isActive(repo.path)}
|
|
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>
|
|
</div>
|
|
{/each}
|
|
<button class="repository-add" type="button" onclick={onAdd} disabled={isBusy} title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"} aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}><Plus size={18}/></button>
|
|
</nav>
|
|
<div class="workspace-picker"><SelectMenu options={workspaceOptions} value={workspaceId} onChange={onWorkspaceChange} disabled={isBusy} ariaLabel={language === "de" ? "Workspace wechseln" : "Switch workspace"} /></div>
|
|
</div>
|
|
{/if}
|
|
</header>
|
|
|
|
<style>
|
|
.repository-row{display:flex;min-width:0;align-items:stretch;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
|
|
.workspace-picker{flex:0 0 180px;min-width:0;align-self:center;padding:3px 5px 3px 8px;border-left:1px solid var(--color-border-subtle)}
|
|
.workspace-picker :global(.select-menu-trigger){min-height:28px;height:28px;font-size:12px}
|
|
@media(max-width:600px){.workspace-picker{flex-basis:140px}}
|
|
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)}
|
|
.global-navigation{display:flex;align-items:stretch;gap:2px;min-height:38px;padding:0 5px;overflow-x:auto;scrollbar-width:thin;border-bottom:1px solid var(--color-border-subtle)}
|
|
button{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
|
|
button:disabled{opacity:.45;cursor:default}
|
|
button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-3px}
|
|
.global-navigation button{position:relative;display:flex;flex:0 0 auto;align-items:center;gap:7px;padding:0 10px;min-height:38px;font-size:13px;white-space:nowrap}
|
|
.global-navigation button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}
|
|
.global-navigation button.active{color:var(--color-ink);font-weight:600}
|
|
.global-navigation button.active::after{position:absolute;content:"";height:2px;bottom:0;left:12px;right:12px;background:var(--color-accent)}
|
|
.global-navigation button.active>:global(svg){color:var(--color-accent)}
|
|
.repository-navigation{flex:1;min-width:0;display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,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: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:var(--app-menu-shadow)}
|
|
.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:pointer}
|
|
.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 span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.repository-select>:global(svg){flex-shrink:0}
|
|
.repository-tab.active .repository-select{font-weight:600}
|
|
.repository-close{display:grid;place-items:center;flex:0 0 24px;height:24px;margin-right:2px;color:var(--color-ink-dim)}
|
|
.repository-close:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
|
.repository-add{display:grid;place-items:center;flex:0 0 32px;min-height:29px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0}
|
|
.repository-add:hover:not(:disabled){color:var(--color-accent);background:var(--color-surface-hover)}
|
|
@media(max-width:640px){.global-navigation{gap:0;padding:0 4px}.global-navigation button{gap:7px;padding:0 10px;font-size:12px;min-height:38px}.global-navigation{min-height:38px}}
|
|
</style>
|