feat(management): extract repository management into dashboard component

Move the inline repository management UI into a new RepositoryDashboard component
and wire it into the application. Refactor app state to compute a reactive
dashboardRepos list and update tab-closing logic so tabs can be closed while
preserving the management view when appropriate.

- Add a full dashboard with search, categories, workspaces and persisted prefs.
- Refactor close behavior to support keeping the management view open.
- Improve UI/CSS and accessibility for the management tab (compact layout and label).
This commit is contained in:
2026-09-05 23:45:23 +02:00
parent 4a4a13dcf8
commit fcd884ee0b
5 changed files with 288 additions and 178 deletions
@@ -0,0 +1,248 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, Plus, Search, Star, Trash2, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
interface DashboardRepository {
path: string;
name: string;
branch: string | null;
ahead: number;
behind: number;
changed: number;
lastOpened: number;
known: boolean;
favorite: boolean;
isOpen: boolean;
recent: boolean;
}
interface Workspace { id: string; name: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let isBusy = false;
export let onOpen: (path: string) => unknown;
export let onClose: (path: string) => unknown;
export let onAdd: () => unknown;
export let onClone: () => unknown;
export let onInit: () => unknown;
export let onFavorite: (path: string) => unknown;
export let onRemoveRecent: (path: string) => unknown;
const STORAGE_KEY = "gitty.dashboard.v1";
let query = "";
let selectedWorkspace = "";
let collapsedCategories = new Set<string>();
let workspaces: Workspace[] = [];
let assignments: Record<string, string> = {};
let workspaceDialog: HTMLDialogElement;
let workspaceInput: HTMLInputElement;
let workspaceName = "";
let workspaceError = "";
let workspaceSelection = new Set<string>();
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: filteredRepos = repos.filter((repo) => {
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
});
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
$: categories = [
{ id: "open", label: "Open", repos: openRepos },
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
{ id: "recent", label: "Recent", repos: recentRepos },
];
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
$: workspaceOptions = [
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
];
loadPreferences();
function loadPreferences() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown };
if (Array.isArray(saved.workspaces)) {
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
item && typeof item === "object"
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
&& "name" in item && typeof item.name === "string" && item.name.trim(),
));
}
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
}
} catch {
workspaces = [];
assignments = {};
}
}
function savePreferences() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); }
catch { /* Preferences remain optional when storage is unavailable. */ }
}
function openWorkspaceDialog() {
workspaceName = "";
workspaceError = "";
workspaceSelection = new Set();
workspaceDialog.showModal();
requestAnimationFrame(() => workspaceInput.focus());
}
function toggleWorkspaceRepo(path: string) {
const next = new Set(workspaceSelection);
if (next.has(path)) next.delete(path);
else next.add(path);
workspaceSelection = next;
}
function createWorkspace(event: SubmitEvent) {
event.preventDefault();
const name = workspaceName.trim();
if (!name) {
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
return;
}
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
return;
}
const id = `workspace-${crypto.randomUUID()}`;
workspaces = [...workspaces, { id, name }];
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
selectedWorkspace = id;
savePreferences();
workspaceDialog.close();
}
function deleteSelectedWorkspace() {
if (!selectedWorkspace) return;
const deletedWorkspace = selectedWorkspace;
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
selectedWorkspace = "";
savePreferences();
}
function openCard(path: string) { if (!isBusy) onOpen(path); }
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
function toggleCategory(id: string) {
const next = new Set(collapsedCategories);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedCategories = next;
}
</script>
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
<header class="dashboard-header">
<h1>Repository Management</h1>
<div class="header-actions">
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
<button class="primary" type="button" onclick={onAdd} disabled={isBusy}><Plus size={14} />Repository</button>
</div>
</header>
<div class="dashboard-toolbar">
<label class="dashboard-search">
<Search size={14} />
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
</label>
<div class="workspace-tools">
<SelectMenu
class="workspace-select"
value={selectedWorkspace}
options={workspaceOptions}
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
onChange={(value) => { selectedWorkspace = value; }}
/>
{#if selectedWorkspace}
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
{/if}
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
</div>
</div>
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-content">
{#each categories as category (category.id)}
<section class="dashboard-section">
<header class="section-header">
<button class="section-toggle" type="button" aria-expanded={!collapsedCategories.has(category.id)} onclick={() => toggleCategory(category.id)}>
{#if collapsedCategories.has(category.id)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<strong>{category.label}</strong><span>{category.repos.length}</span><i></i>
</button>
</header>
{#if !collapsedCategories.has(category.id)}
{#if category.repos.length > 0}
<div class="repo-grid">
{#each category.repos as repo (repo.path)}
<article class="repo-card" class:open={repo.isOpen}>
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}>
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
<span class="card-path">{repo.path}</span>
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
<span class="card-status">
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
</span>
</button>
<div class="card-actions">
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="category-empty">{de ? "Keine Repositories in dieser Kategorie." : "No repositories in this category."}</p>
{/if}
{/if}
</section>
{/each}
</div>
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
</section>
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
<form onsubmit={createWorkspace}>
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
<label for="workspace-name">Name</label>
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
{#if repos.length > 0}
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
</div></fieldset>
{/if}
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
</form>
</dialog>
<style>
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 70px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
</style>