Add pull request badges to the repository dashboard and enable deep- linking to the Review Center with a repository and source pre-filter. Badges are populated by resolving remotes, matching them to configured integration sources, and querying providers for open PRs while handling loading, error, and unavailable states. The dashboard badge opens the Review Center pre-filled and the app stores initial query/source values to support the deep-link. - Resolve and normalize remotes to match integration sources reliably. - Query provider APIs using stored credentials with sensible timeouts. - Expose a badge action that navigates to the Review Center pre-filtered.
409 lines
30 KiB
Svelte
409 lines
30 KiB
Svelte
<script lang="ts">
|
||
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
||
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; }
|
||
interface PullRequestBadgeState {
|
||
status: "loading" | "ready" | "error" | "unavailable";
|
||
count: number;
|
||
sourceId: string;
|
||
repository: string;
|
||
message: string;
|
||
}
|
||
|
||
interface RemoteIdentity { host: string; repository: string; organization: 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 integrations: GitIntegrationSettings;
|
||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||
export let onOpenPullRequests: (repository: string, sourceId: 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>();
|
||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||
let pullRequestLoadGeneration = 0;
|
||
let lastPullRequestLoadKey = "";
|
||
|
||
$: 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" })),
|
||
];
|
||
$: integrationSources = configuredIntegrationSources(integrations);
|
||
$: pullRequestLoadKey = JSON.stringify({
|
||
repositories: repos.map((repo) => repo.path),
|
||
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
|
||
});
|
||
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
|
||
lastPullRequestLoadKey = pullRequestLoadKey;
|
||
void loadPullRequestBadges();
|
||
}
|
||
|
||
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 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 remoteIdentity(value: string): RemoteIdentity | null {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return null;
|
||
let host = "";
|
||
let path = "";
|
||
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
||
if (scp) {
|
||
host = scp[1];
|
||
path = scp[2];
|
||
} else {
|
||
try {
|
||
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||
host = url.hostname;
|
||
path = url.pathname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
|
||
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
|
||
const normalizedHost = host.toLocaleLowerCase();
|
||
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
|
||
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
|
||
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
|
||
}
|
||
|
||
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
|
||
try {
|
||
const sourceUrl = new URL(source.baseUrl);
|
||
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
|
||
const remoteHost = identity.host;
|
||
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
|
||
if (source.provider !== "azure-devops") return true;
|
||
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
|
||
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
|
||
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
|
||
return !organization || !identity.organization || organization === identity.organization;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
|
||
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
|
||
if (!requestRepository) return false;
|
||
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
|
||
const requestParts = requestRepository.split("/");
|
||
const remoteParts = repository.split("/");
|
||
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
|
||
}
|
||
|
||
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
|
||
if (generation !== pullRequestLoadGeneration) return;
|
||
pullRequestBadges = { ...pullRequestBadges, [path]: state };
|
||
}
|
||
|
||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||
return new Promise<T>((resolve, reject) => {
|
||
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
||
});
|
||
}
|
||
|
||
async function loadPullRequestBadges() {
|
||
const generation = ++pullRequestLoadGeneration;
|
||
const sources = integrationSources;
|
||
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
|
||
status: sources.length ? "loading" : "unavailable",
|
||
count: 0,
|
||
sourceId: "",
|
||
repository: "",
|
||
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
|
||
} satisfies PullRequestBadgeState]));
|
||
if (!repos.length || !sources.length) return;
|
||
|
||
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
||
await Promise.all(repos.map(async (repo) => {
|
||
try {
|
||
const remotes = await listRemotes(repo.path);
|
||
const identities = remotes
|
||
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
|
||
.map((remote) => remoteIdentity(remote.fetch_url))
|
||
.filter((identity): identity is RemoteIdentity => Boolean(identity));
|
||
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
|
||
if (!match?.source) {
|
||
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
|
||
return;
|
||
}
|
||
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
|
||
} catch (error) {
|
||
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
|
||
}
|
||
}));
|
||
|
||
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
|
||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||
if (!source) return;
|
||
try {
|
||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
|
||
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
|
||
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
|
||
for (const { repo, identity } of entries) {
|
||
updatePullRequestBadge(repo.path, {
|
||
status: "ready",
|
||
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
|
||
sourceId,
|
||
repository: identity.repository,
|
||
message: "",
|
||
}, generation);
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
|
||
}
|
||
}));
|
||
}
|
||
|
||
function pullRequestTitle(repo: DashboardRepository): string {
|
||
const badge = pullRequestBadges[repo.path];
|
||
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;
|
||
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
||
}
|
||
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)}
|
||
{@const pullRequests = pullRequestBadges[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: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(repo)}`} title={pullRequestTitle(repo)}>
|
||
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else 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}<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: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 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-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)}.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}}
|
||
</style>
|