Features/extend git features #20

Merged
Christoph merged 6 commits from features/extend_git_features into main 2026-07-06 19:07:49 +00:00
Showing only changes of commit d1d3604a6d - Show all commits
+148 -19
View File
@@ -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,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<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}`}>