Files
GitLite/src/lib/components/RepositoryDashboard.svelte
T
Christoph 1b2460761f feat(dashboard): extract PR status loader and surface badges
Move pull-request discovery and caching into a dedicated background
loader component. The repository dashboard no longer performs the
network/keychain work itself and instead receives a shared badge state
that is refreshed and persisted independently. The app wires the loader
into top-level state and binds badge data into the dashboard view.

- Add a background loader to fetch, group and cache open PR counts.
- Simplify dashboard to accept and render pull-request badge state.
- Wire loader into app-level state and bind badges to the dashboard.
2026-09-08 10:04:20 +02:00

338 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import type { PullRequestBadgeState } from "../pullRequestBadges";
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;
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
const STORAGE_KEY = "gitty.dashboard.v1";
let query = "";
let viewMode: "cards" | "list" = "list";
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>();
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
$: 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; viewMode?: unknown };
viewMode = saved.viewMode === "cards" ? "cards" : "list";
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, viewMode })); }
catch { /* Preferences remain optional when storage is unavailable. */ }
}
function setViewMode(mode: "cards" | "list") {
viewMode = mode;
savePreferences();
}
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 openPullRequests(event: MouseEvent, repo: DashboardRepository) {
event.stopPropagation();
const badge = pullRequestBadges[repo.path];
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
}
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
const title = de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
return badge.message ? `${title} (${de ? "Letzter bekannter Stand" : "Last known status"}: ${badge.message})` : title;
}
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="view-switch" role="group" aria-label={de ? "Repository-Ansicht" : "Repository view"}>
<button type="button" class:active={viewMode === "list"} aria-pressed={viewMode === "list"} aria-label={de ? "Listenansicht" : "List view"} title={de ? "Listenansicht" : "List view"} onclick={() => setViewMode("list")}><List size={16} /></button>
<button type="button" class:active={viewMode === "cards"} aria-pressed={viewMode === "cards"} aria-label={de ? "Kachelansicht" : "Card view"} title={de ? "Kachelansicht" : "Card view"} onclick={() => setViewMode("cards")}><LayoutGrid size={15} /></button>
</div>
<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" class:table-view={viewMode === "list"}>
{#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" class:list-view={viewMode === "list"} class:tiles-view={viewMode === "cards"}>
{#each category.repos as repo (repo.path)}
{@const pullRequests = pullRequestBadges[repo.path]}
<article class="repo-card" class:open={repo.isOpen} class:favorite-category={category.id === "favorites"}>
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy}>
<span class="card-title"><FolderGit2 size={15} /><strong title={repo.path}>{repo.name}</strong></span>
<span class="card-branch" title={repo.branch ?? undefined}><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
<span class="card-status">
{#if !repo.known}<span class="muted"><Circle class="filled" size={9} />{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}
</span>
<span class="list-sync">{#if repo.known}<span class:ahead={repo.ahead > 0} title={de ? `${repo.ahead} Commits voraus` : `${repo.ahead} commits ahead`}><ArrowUp size={14} />{repo.ahead}</span><span class:behind={repo.behind > 0} title={de ? `${repo.behind} Commits zurück` : `${repo.behind} commits behind`}><ArrowDown size={14} />{repo.behind}</span>{:else}<span class="muted"></span>{#if viewMode === "cards"}<span class="muted"></span>{/if}{/if}</span>
</button>
<div class="card-actions">
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
{#if pullRequests?.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests?.status === "ready" ? pullRequests.count : ""}</span>{/if}
</button>
<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 && category.id === "open"}<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:8px;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:20px}.section-header{min-height:31px;margin-bottom:6px}.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 104px 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-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{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.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)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,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}}
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
.view-switch button{display:grid;width:32px;min-height:0;height:28px;place-items:center;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent}
.view-switch button+button{border-left:1px solid var(--color-border-subtle)}
.view-switch button.active{color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 12%,var(--app-input-bg));box-shadow:inset 0 -2px var(--color-accent)}
.view-switch button:hover{color:var(--color-ink);background:var(--color-surface-hover)}
.view-switch button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
/* Approved list design: shared columns across every category. */
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .repo-card{display:grid;grid-template-columns:minmax(0,1fr) minmax(170px,22%)}
.list-view .card-main{display:grid;grid-template-columns:var(--repo-list-columns);gap:16px;padding:0 18px;align-items:center}
.repo-grid.list-view{display:block;border:0}
.table-view .repo-card{height:auto;min-height:48px;border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.table-view .repo-card:hover,.table-view .repo-card:focus-within{background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface))}
.table-view .repo-card:focus-within{box-shadow:inset 2px 0 var(--color-accent)}
.list-view .card-main{height:auto;min-height:48px;min-width:0;justify-content:stretch;align-content:center;text-align:left}
.list-view .card-title{min-width:0;gap:9px}.list-view .card-title strong{font-size:12px;font-weight:500}.list-view .card-title :global(svg){flex:none}
.list-view .card-branch{min-width:0;font-size:12px}.list-view .card-branch :global(svg){flex:none}
.list-view .card-status{margin:0;gap:5px;font-size:12px;flex-wrap:nowrap;white-space:nowrap}.list-view .card-status .muted{font-style:italic}
.list-sync{display:flex;align-items:center;gap:18px;color:var(--color-ink-faint);font-size:12px}.list-sync>span{display:flex;align-items:center;gap:5px}
.list-view .card-actions{position:static;display:grid;grid-template-columns:minmax(60px,1fr) 40px 40px;gap:0;align-items:center;margin:0}
.list-view .card-actions .pr-badge{justify-self:start;justify-content:flex-start;padding:0;gap:8px;color:var(--color-accent)}
.list-view .pr-badge span{min-width:18px;padding:1px 4px;border:1px solid var(--color-border);border-radius:2px;color:var(--color-ink-muted);font-size:11px;text-align:center}
.list-view .card-actions button{height:28px;width:28px}.list-view .card-actions button :global(svg){width:16px;height:16px}
.list-view .card-actions button.active :global(svg){fill:var(--color-accent)}
.table-view .category-empty{border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.list-view .card-actions .pr-badge{width:auto;min-width:60px}.list-view .card-actions .pr-badge :global(svg){flex:0 0 16px}
/* Approved tile layout: repository identity above a shared status footer. */
.tiles-view{gap:12px}
.tiles-view .repo-card{height:150px;border-radius:3px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.tiles-view .repo-card:focus-within{box-shadow:inset 3px 0 var(--color-accent)}
.tiles-view .card-main{display:grid;grid-template-columns:minmax(0,1fr) 104px 42px;grid-template-rows:24px 24px 1fr 30px;gap:0 10px;padding:16px 16px 10px;align-items:center;align-content:stretch}
.tiles-view .card-main:after{position:absolute;left:16px;right:16px;bottom:51px;height:1px;background:var(--color-border);content:"";pointer-events:none}
.tiles-view .card-title{grid-column:1/-1;grid-row:1;min-width:0;padding:0 60px 0 48px;max-width:100%;display:block}
.tiles-view .card-title strong{display:block;font-size:13px;font-weight:650;line-height:22px}
.tiles-view .card-title>:global(svg){position:absolute;top:21px;left:18px;width:32px;height:32px;color:var(--color-ink-muted);stroke-width:1.4}
.tiles-view .card-branch{grid-column:1/-1;grid-row:2;padding-left:48px;min-width:0;display:block;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:22px}
.tiles-view .card-branch>:global(svg){display:inline-block;margin-right:6px;vertical-align:-2px}
.tiles-view .card-status{grid-column:1;grid-row:4;min-width:0;flex-wrap:nowrap;margin:0;font-size:11px;gap:0;white-space:nowrap}
.tiles-view .card-status>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.tiles-view .list-sync{grid-column:2;grid-row:4;gap:16px;font-size:11px}
.tiles-view .card-actions{position:static;display:contents}
.tiles-view .card-actions button{position:absolute;top:14px;right:14px;width:26px;height:26px}
.tiles-view .card-actions button[aria-pressed]{right:44px}
.tiles-view .card-actions .pr-badge{top:auto;bottom:12px;right:14px;width:42px;min-width:0;height:26px;gap:6px;padding:0;font-size:12px;color:var(--color-ink-muted)}
.tiles-view .card-actions .pr-badge.has-open-prs{color:var(--color-accent)}
.tiles-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.tiles-view .card-actions .pr-badge>:global(svg){width:16px;height:16px;flex:none}
.tiles-view .card-actions button.active>:global(svg){fill:var(--color-accent)}
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .dashboard-section{min-width:900px}
.list-view .card-title strong,.list-view .card-branch,.list-view .card-status{font-size:13px}
.list-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.list-view .card-actions .pr-badge:disabled{color:var(--color-ink-faint)}
@media(min-width:1001px) and (max-width:1150px){.tiles-view{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:420px){.tiles-view .card-main{grid-template-columns:minmax(0,1fr) 82px 34px;gap:0 6px;padding-right:12px;padding-left:12px}.tiles-view .list-sync{gap:8px}.tiles-view .card-title{padding-left:42px}.tiles-view .card-branch{padding-left:42px}.tiles-view .card-title>:global(svg){left:14px;width:28px;height:28px}.tiles-view .card-status{font-size:10px}}
.tiles-view .card-title strong{font-size:14px}
.tiles-view .card-branch,.tiles-view .card-status,.tiles-view .list-sync{font-size:12px}
.tiles-view .card-status .changed,.tiles-view .card-status .clean{color:var(--color-ink-muted)}
.tiles-view .card-status .changed>:global(svg){color:#eeb94e;flex:none}.tiles-view .card-status .clean>:global(svg){color:#68c878;flex:none}
.tiles-view .favorite-category .card-actions button[aria-pressed]{right:14px}
.tiles-view .favorite-category .card-title{padding-right:30px}
.list-view .card-status .changed,.list-view .card-status .clean{color:var(--color-ink-muted)}
.list-view .card-status .changed>:global(svg){color:#eeb94e}.list-view .card-status .clean>:global(svg){color:#68c878}
</style>