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:
@@ -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>
|
||||
Reference in New Issue
Block a user