feat(app): add background commit notes fetch and refresh logic

This change adds a background path for fetching commit notes per repo
and a shared cache to avoid duplicate work. When notes are fetched
and the active repo is visible, history is refreshed to reflect
notes without blocking user actions.

- Adds per-path backgroundCommitNotesFetches cache to debounce fetches
- Integrates note fetch into the background tick and refresh flow
- Handles credential errors and shutdown gracefully during fetches
This commit is contained in:
Christoph Brandau
2026-08-13 22:56:53 +02:00
parent a27a8666ee
commit fe577d78a8
+105 -6
View File
@@ -427,6 +427,7 @@
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined; let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
let backgroundRepoStatusInFlight = false; let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0; let backgroundRepoStatusIndex = 0;
let appShuttingDown = false; let appShuttingDown = false;
@@ -672,6 +673,15 @@
// Keep the cached tab data if this repo is unavailable at startup. // Keep the cached tab data if this repo is unavailable at startup.
} }
} }
const notesFetched = await backgroundFetchCommitNotes(path);
if (notesFetched && sameRepoPath(path, activeRepoPath)) {
try {
await refreshCommitHistory(path);
} catch {
// The active repository may still be opening; its own background pass retries.
}
}
} }
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
@@ -717,18 +727,91 @@
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually. // ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// 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.
function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
return remotes.find((remote) => remote.name === selectedRemote)
?? remotes.find((remote) => remote.name === "origin")
?? remotes[0];
}
async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
if (appShuttingDown || !autoRefreshEnabled || !path) return false;
if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;
let credentialKey: string | null = null;
try {
const remote = preferredNotesRemote(await listRemotes(path));
if (!remote) return false;
credentialKey = orgKeyFromUrl(remote.fetch_url);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
const credential = await loadStoredCredential(credentialKey);
if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
trackEvent("commit_notes_background_fetched");
return true;
} catch (error) {
if (credentialKey && isAuthError(errorToMessage(error))) {
rejectedCredentialKeys.add(credentialKey);
}
if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
return false;
}
}
function backgroundFetchCommitNotes(path: string): Promise<boolean> {
const key = repoKey(path);
const current = backgroundCommitNotesFetches.get(key);
if (current) return current;
const request = backgroundFetchCommitNotesCore(path).finally(() => {
backgroundCommitNotesFetches.delete(key);
});
backgroundCommitNotesFetches.set(key, request);
return request;
}
async function waitForBackgroundCommitNotes(path: string) {
await backgroundCommitNotesFetches.get(repoKey(path));
}
async function backgroundFetchCommitNotesAndRefresh(path: string) {
if (!await backgroundFetchCommitNotes(path)) return;
if (!sameRepoPath(path, activeRepoPath)) return;
try {
await refreshCommitHistory(path);
} catch {
// Repository changes can invalidate this best-effort background refresh.
}
}
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (appShuttingDown || !autoRefreshEnabled) return; if (appShuttingDown || !autoRefreshEnabled) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
const path = activeRepoPath;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); let refsFetched = false;
applyStatus(await getStatus(activeRepoPath)); try {
await refreshRefsAndCommitGraph(activeRepoPath); await fetchRemote(path);
refsFetched = true;
} catch { } catch {
// ignore — see comment above // Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (sameRepoPath(path, activeRepoPath)) {
if (refsFetched) {
applyStatus(await getStatus(path));
await refreshRefsAndCommitGraph(path);
} else if (notesFetched) {
await refreshCommitHistory(path);
}
}
} catch {
// ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -741,15 +824,27 @@
if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return; if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try {
let refsFetched = false;
try { try {
await fetchRemote(path); await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (refsFetched) {
const nextStatus = await getStatus(path); const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) { if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus); applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path); await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus); } else updateRepoManagementStatus(path, nextStatus);
} else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
await refreshCommitHistory(path);
}
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -2180,7 +2275,8 @@
changed_files: bundle.status.files.length, changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0, has_upstream: bundle.status.upstream ? 1 : 0,
}); });
void backgroundFetchRepo(activeRepoPath); if (backgroundFetchInFlight) void backgroundFetchCommitNotesAndRefresh(activeRepoPath);
else void backgroundFetchRepo(activeRepoPath);
}); });
} }
@@ -3011,6 +3107,7 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await setCommitNote(repo, commit.hash, note); await setCommitNote(repo, commit.hash, note);
commitNoteText = note; commitNoteText = note;
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item); commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
@@ -3033,6 +3130,7 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await deleteCommitNote(repo, commit.hash); await deleteCommitNote(repo, commit.hash);
commitNoteText = ""; commitNoteText = "";
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item); commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
@@ -3068,6 +3166,7 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
const credential = await storedCredentialForNoteRemote(remote, direction); const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") { if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password); await fetchCommitNotes(repo, remote, credential?.username, credential?.password);