From d1d3604a6d97ba0d9408bf497dc15aa5dafcb9fe Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 6 Jul 2026 20:04:13 +0200 Subject: [PATCH] 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 --- src/App.svelte | 167 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 19 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 1006618..ed43f8b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -146,6 +146,7 @@ const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; + const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; @@ -164,6 +165,9 @@ let activeView: AppView = "management"; let repoTabs: RepoTab[] = []; 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 = {}; let repoSearch = ""; let cloneDialogOpen = false; let cloneDialogError = ""; @@ -234,10 +238,16 @@ let lastStatusFingerprint = ""; const AUTO_REFRESH_INTERVAL = 4000; let autoRefreshTimer: ReturnType | undefined; + const BACKGROUND_REPO_STATUS_INTERVAL = 30_000; + const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4; const BACKGROUND_FETCH_INTERVAL = 180_000; const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000; + const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2; let backgroundFetchTimer: ReturnType | undefined; + let backgroundRepoStatusTimer: ReturnType | undefined; let backgroundFetchInFlight = false; + let backgroundRepoStatusInFlight = false; + let backgroundRepoStatusIndex = 0; let lastRepoSwitchAt = 0; let updateToastOpen = false; let updateToastState: UpdateToastState = "available"; @@ -313,13 +323,16 @@ onMount(() => { loadRepoLists(); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); + backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL); backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL); + void backgroundRepoStatusTick(false); void checkForUpdates(); void initCommitAi(); }); onDestroy(() => { if (autoRefreshTimer) clearInterval(autoRefreshTimer); + if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); @@ -364,15 +377,78 @@ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Push buttons instead, not as a background popup. async function backgroundFetchTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return; - if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return; - backgroundFetchInFlight = true; + if (!autoRefreshEnabled) return; + + if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight + && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { + backgroundFetchInFlight = true; + try { + await fetchRemote(activeRepoPath); + applyStatus(await getStatus(activeRepoPath)); + } catch { + // ignore — see comment above + } finally { + backgroundFetchInFlight = false; + } + } + + 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 { - await fetchRemote(activeRepoPath); - } catch { - // ignore — see comment above + // 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 { - backgroundFetchInFlight = false; + backgroundRepoStatusInFlight = false; } } @@ -649,7 +725,13 @@ } 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, name: repoNameFromPath(path), branch: null, @@ -669,6 +751,11 @@ function loadRepoLists() { try { + const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown; + repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue) + ? cacheValue as Record + : {}; + const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown; const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown; const openPaths = Array.isArray(openValue) @@ -678,19 +765,19 @@ ? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean) : []; - repoTabs = uniqueRepoPaths(openPaths).map((path) => ({ - path, - name: repoNameFromPath(path), - branch: null, - ahead: 0, - behind: 0, - changed: 0, - lastOpened: 0, - })); + // Seed from the last-known status cache so tabs show real data immediately + // on startup, instead of blank until the background poll catches up. + repoTabs = uniqueRepoPaths(openPaths).map((path) => { + const cached = repoStatusCache[repoKey(path)]; + return cached + ? { ...cached, path, name: repoNameFromPath(path) } + : { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 }; + }); recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]); } catch { repoTabs = []; 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 { return { provider: "openai", @@ -860,6 +955,16 @@ ? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab) : [...repoTabs, next]; 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) { @@ -1064,6 +1169,16 @@ // and the overlay appears to "come late". await tick(); await new Promise((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, // commits and files in one pass instead of four sequential git calls. const bundle = await openRepositoryBundle(path, 100); @@ -1186,6 +1301,7 @@ function openRepoManagement() { if (isBusy) return; activeView = "management"; + void backgroundRepoStatusTick(false); } async function selectRepoTab(path: string) { @@ -1218,6 +1334,11 @@ if (isBusy) return; recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path)); persistRepoLists(); + if (repoStatusCache[repoKey(path)]) { + const { [repoKey(path)]: _removed, ...rest } = repoStatusCache; + repoStatusCache = rest; + persistRepoStatusCache(); + } if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) { await closeRepoTab(path); } @@ -2502,7 +2623,12 @@