feat(repo-status): cache and background-refresh repo ahead/behind

Repo branch status is now cached locally and refreshed in the
background so management and tab lists show up-to-date data without
manual fetches. The background worker updates repos in a round-robin
batch to avoid running multiple git subprocesses at once, and the UI
renders ahead/behind/changed counts alongside branch names.

- Add localStorage-backed repo status cache for known repos
- Background polling updates open tabs and recent/known repos
- Show ahead/behind/changed metadata in repository management UI
This commit is contained in:
Christoph Brandau
2026-07-06 20:04:13 +02:00
parent ce0cbc0786
commit d1d3604a6d
+143 -14
View File
@@ -146,6 +146,7 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
@@ -164,6 +165,9 @@
let activeView: AppView = "management"; let activeView: AppView = "management";
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = []; let recentRepoPaths: string[] = [];
// Last-seen branch/ahead/behind/changed for repos that are known (recent/all)
// but not currently open as a tab — keyed by normalized path (repoKey).
let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false; let cloneDialogOpen = false;
let cloneDialogError = ""; let cloneDialogError = "";
@@ -234,10 +238,16 @@
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_REPO_STATUS_INTERVAL = 30_000;
const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4;
const BACKGROUND_FETCH_INTERVAL = 180_000; const BACKGROUND_FETCH_INTERVAL = 180_000;
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000; const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0;
let lastRepoSwitchAt = 0; let lastRepoSwitchAt = 0;
let updateToastOpen = false; let updateToastOpen = false;
let updateToastState: UpdateToastState = "available"; let updateToastState: UpdateToastState = "available";
@@ -313,13 +323,16 @@
onMount(() => { onMount(() => {
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL); backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
void backgroundRepoStatusTick(false);
void checkForUpdates(); void checkForUpdates();
void initCommitAi(); void initCommitAi();
}); });
onDestroy(() => { onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
@@ -364,11 +377,14 @@
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return; if (!autoRefreshEnabled) return;
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); await fetchRemote(activeRepoPath);
applyStatus(await getStatus(activeRepoPath));
} catch { } catch {
// ignore — see comment above // ignore — see comment above
} finally { } finally {
@@ -376,6 +392,66 @@
} }
} }
void backgroundRepoStatusTick(true);
}
// Keeps the repo list's branch/ahead/behind up to date for every *other* open
// tab, not just the active one, so switching to the management view (or just
// glancing at the tab bar) shows current data without an explicit fetch.
// One repo per tick, round-robin: spreads the git subprocess cost over time
// instead of firing N fetches at once when many repos are open.
// Every repo we know about besides the active one: open tabs (minus the active
// tab) plus recent repos that aren't currently open — the same universe the
// Recent/All repositories lists in Repository Management draw from.
function knownRepoPathsForBackground(): string[] {
return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
.filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
}
async function backgroundRepoStatusTick(fetchFirst: boolean) {
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
const others = knownRepoPathsForBackground();
if (others.length === 0) return;
backgroundRepoStatusInFlight = true;
try {
// A handful per tick, sequentially (not in parallel) — enough to fill in
// a long recent-repos list within a few minutes instead of an hour, while
// still never running more than one `git fetch` subprocess at a time.
const batchSize = fetchFirst ? BACKGROUND_FETCH_OTHER_BATCH_SIZE : BACKGROUND_REPO_STATUS_BATCH_SIZE;
for (let step = 0; step < Math.min(batchSize, others.length); step++) {
if (backgroundRepoStatusIndex >= others.length) backgroundRepoStatusIndex = 0;
const path = others[backgroundRepoStatusIndex];
backgroundRepoStatusIndex += 1;
try {
if (fetchFirst) await fetchRemote(path);
const nextStatus = await getStatus(path);
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const cached = repoStatusCache[repoKey(path)];
const row: RepoTab = {
path,
name: repoNameFromPath(path),
branch: nextStatus.current_branch,
ahead: nextStatus.ahead,
behind: nextStatus.behind,
changed: nextStatus.files.length,
lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0,
};
if (openTab) {
repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab);
}
cacheRepoStatus(row);
} catch {
// ignore this repo — same rationale as the active-repo background fetch above
}
}
} finally {
backgroundRepoStatusInFlight = false;
}
}
async function autoRefreshTick() { async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true; autoRefreshInFlight = true;
@@ -649,7 +725,13 @@
} }
function repoRowFromPath(path: string): RepoTab { function repoRowFromPath(path: string): RepoTab {
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? { const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
if (openTab) return openTab;
const cached = repoStatusCache[repoKey(path)];
if (cached) return { ...cached, path, name: repoNameFromPath(path) };
return {
path, path,
name: repoNameFromPath(path), name: repoNameFromPath(path),
branch: null, branch: null,
@@ -669,6 +751,11 @@
function loadRepoLists() { function loadRepoLists() {
try { try {
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue)
? cacheValue as Record<string, RepoTab>
: {};
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown; const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown; const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
const openPaths = Array.isArray(openValue) const openPaths = Array.isArray(openValue)
@@ -678,19 +765,19 @@
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean) ? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: []; : [];
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({ // Seed from the last-known status cache so tabs show real data immediately
path, // on startup, instead of blank until the background poll catches up.
name: repoNameFromPath(path), repoTabs = uniqueRepoPaths(openPaths).map((path) => {
branch: null, const cached = repoStatusCache[repoKey(path)];
ahead: 0, return cached
behind: 0, ? { ...cached, path, name: repoNameFromPath(path) }
changed: 0, : { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 };
lastOpened: 0, });
}));
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]); recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
} catch { } catch {
repoTabs = []; repoTabs = [];
recentRepoPaths = []; recentRepoPaths = [];
repoStatusCache = {};
} }
} }
@@ -703,6 +790,14 @@
} }
} }
function persistRepoStatusCache() {
try {
localStorage.setItem(REPO_STATUS_CACHE_KEY, JSON.stringify(repoStatusCache));
} catch {
// Local storage is best-effort only; the Git workflow must keep working without it.
}
}
function defaultAiSettings(): AiSettings { function defaultAiSettings(): AiSettings {
return { return {
provider: "openai", provider: "openai",
@@ -860,6 +955,16 @@
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab) ? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
: [...repoTabs, next]; : [...repoTabs, next];
rememberRecentRepo(path); rememberRecentRepo(path);
cacheRepoStatus(next);
}
// Keeps last-known branch/ahead/behind/changed around under the repo's
// normalized path, independent of repoTabs — so a closed tab (or a repo
// that's only ever shown up in "recent") still displays real data instead
// of the "known repo" placeholder in the Recent/All repositories lists.
function cacheRepoStatus(row: RepoTab) {
repoStatusCache = { ...repoStatusCache, [repoKey(row.path)]: row };
persistRepoStatusCache();
} }
function resetRepositoryState(clearActive = false) { function resetRepositoryState(clearActive = false) {
@@ -1064,6 +1169,16 @@
// and the overlay appears to "come late". // and the overlay appears to "come late".
await tick(); await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Fetch first so the bundle's ahead/behind reflects the remote's current
// state instead of whatever the local remote-tracking ref last saw. Best
// effort: no credentials are passed and failures are swallowed, same as
// the periodic background fetch — auth/network issues surface via the
// manual Fetch/Pull buttons instead of blocking (or erroring) repo open.
try {
await fetchRemote(path);
} catch {
// ignore
}
// Single backend round-trip: resolves the repo and reads status, branches, // Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls. // commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100); const bundle = await openRepositoryBundle(path, 100);
@@ -1186,6 +1301,7 @@
function openRepoManagement() { function openRepoManagement() {
if (isBusy) return; if (isBusy) return;
activeView = "management"; activeView = "management";
void backgroundRepoStatusTick(false);
} }
async function selectRepoTab(path: string) { async function selectRepoTab(path: string) {
@@ -1218,6 +1334,11 @@
if (isBusy) return; if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path)); recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists(); persistRepoLists();
if (repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
repoStatusCache = rest;
persistRepoStatusCache();
}
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) { if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
await closeRepoTab(path); await closeRepoTab(path);
} }
@@ -2502,7 +2623,12 @@
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}> <button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span> <span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span> <span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta quiet">recent</span> <span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" /> <X size={14} aria-hidden="true" />
@@ -2528,7 +2654,10 @@
<span class="repo-row-name">{repo.name}</span> <span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span> <span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta"> <span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if} {#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">known repo</em>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span> </span>
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>