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.
This commit is contained in:
2026-09-08 10:04:20 +02:00
parent e7d6a6e4f4
commit 1b2460761f
4 changed files with 209 additions and 149 deletions
+9 -1
View File
@@ -10,6 +10,9 @@
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte";
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
@@ -5548,10 +5551,15 @@
</section>
{/if}
<PullRequestStatusLoader
repos={dashboardRepos} language={appLanguage}
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
bind:pullRequestBadges
/>
{#if activeView === "management"}
<RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy}
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
{pullRequestBadges}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
@@ -0,0 +1,186 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
import type { PullRequestBadgeState } from "../pullRequestBadges";
interface DashboardRepository { path: string; }
interface RemoteIdentity { host: string; repository: string; organization: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
const STORAGE_KEY = "gitty.pull-request-badges.v1";
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
let mounted = false;
let loading = false;
$: de = language === "de";
$: integrationSources = configuredIntegrationSources(integrations);
$: sourceKey = JSON.stringify(integrationSources.map(source => [source.id, source.provider, source.accountId, source.baseUrl]));
$: loadKey = JSON.stringify([repos.map(repo => repo.path).sort(), sourceKey]);
$: if (mounted && loadKey !== lastPullRequestLoadKey) {
lastPullRequestLoadKey = loadKey;
restoreCache();
void refresh();
}
onMount(() => {
mounted = true;
const timer = window.setInterval(() => { if (!loading) void refresh(); }, 3 * 60_000);
return () => window.clearInterval(timer);
});
onDestroy(() => { mounted = false; ++pullRequestLoadGeneration; });
function restoreCache() {
pullRequestBadges = {};
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
if (saved?.sourceKey !== sourceKey || !saved?.badges) return;
for (const repo of repos) {
const badge = saved.badges[repo.path];
if (badge?.status === "ready" && Number.isInteger(badge.count) && badge.count >= 0
&& typeof badge.repository === "string" && typeof badge.sourceId === "string"
&& integrationSources.some(source => source.id === badge.sourceId)) {
pullRequestBadges[repo.path] = { ...badge, message: "" };
}
}
} catch { /* Cache is optional. */ }
}
async function refresh() {
loading = true;
const pending = loadPullRequestBadges();
const generation = pullRequestLoadGeneration;
try { await pending; }
finally {
if (generation === pullRequestLoadGeneration) {
loading = false;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sourceKey, badges: pullRequestBadges })); }
catch { /* Keep in-memory values if storage is unavailable. */ }
}
}
}
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;
if (state.status === "error" && pullRequestBadges[path]?.status === "ready") {
state = { ...pullRequestBadges[path], message: state.message };
}
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, pullRequestBadges[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 withTimeout(listRemotes(repo.path), 15_000, "Remote lookup timed out.");
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);
}
}));
if (generation !== pullRequestLoadGeneration) return;
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);
}
}));
}
</script>
+6 -148
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, 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 { 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 {
@@ -20,16 +18,6 @@
}
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;
@@ -40,8 +28,6 @@
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";
@@ -56,9 +42,7 @@
let workspaceName = "";
let workspaceError = "";
let workspaceSelection = new Set<string>();
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
@@ -79,16 +63,6 @@
{ 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() {
@@ -175,127 +149,11 @@
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(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;
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
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);
@@ -368,7 +226,7 @@
</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 || 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}
{#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}
+8
View File
@@ -0,0 +1,8 @@
export interface PullRequestBadgeState {
status: "loading" | "ready" | "error" | "unavailable";
count: number;
sourceId: string;
repository: string;
message: string;
}