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:
+143
-14
@@ -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<string, RepoTab> = {};
|
||||
let repoSearch = "";
|
||||
let cloneDialogOpen = false;
|
||||
let cloneDialogError = "";
|
||||
@@ -234,10 +238,16 @@
|
||||
let lastStatusFingerprint = "";
|
||||
const AUTO_REFRESH_INTERVAL = 4000;
|
||||
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_AFTER_SWITCH_GRACE_MS = 30_000;
|
||||
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
|
||||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | 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,11 +377,14 @@
|
||||
// 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;
|
||||
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 {
|
||||
@@ -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() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||
autoRefreshInFlight = true;
|
||||
@@ -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<string, RepoTab>
|
||||
: {};
|
||||
|
||||
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<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,
|
||||
// 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 @@
|
||||
<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-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 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" />
|
||||
@@ -2528,7 +2654,10 @@
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<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>
|
||||
</button>
|
||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
||||
|
||||
Reference in New Issue
Block a user