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 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}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user