refactor: centralize git and pr store refresh

This commit is contained in:
Bohdan Triapitsyn
2026-04-03 19:47:01 +03:00
parent 2c56cb021f
commit d982171689
16 changed files with 822 additions and 1157 deletions
+235
View File
@@ -0,0 +1,235 @@
# UI Stores
## Purpose
`packages/ui/src/stores` contains app-level Zustand stores for persistent UI state, runtime state, and feature caches.
Not all state in the UI belongs here.
Use a store when state is:
- shared across distant parts of the app
- needed outside a single component subtree
- cache-like and keyed by runtime identity (for example directory, branch, session id)
- updated imperatively from multiple surfaces
Do not put high-frequency local component state here just because it is convenient.
## Architecture
There are multiple store categories in this directory.
### Feature cache / query stores
These are the most performance-sensitive.
- `useGitStore.ts`
- `useGitHubPrStatusStore.ts`
- `useFilesViewTabsStore.ts`
These stores act like centralized keyed caches. UI should consume narrow slices from them instead of re-fetching the same data in multiple places.
### UI state stores
Examples:
- `useUIStore.ts`
- `useDirectoryStore.ts`
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags.
### Session / project coordination stores
Examples:
- `useProjectsStore.ts`
- `useGlobalSessionsStore.ts`
- `useSessionFoldersStore.ts`
These stores coordinate persistent project/session metadata across multiple views.
## Git / PR Stores
The Git and PR stores are the most important stores to understand before editing this directory.
### `useGitStore.ts`
`useGitStore` is a centralized per-directory Git cache.
Core model:
- top-level keyed by `directory`
- each directory entry contains:
- repo detection
- status
- branches
- log
- identity
- diff cache
- per-directory loading flags
- freshness timestamps
Important properties:
- `directories: Map<string, DirectoryGitState>` is the source of truth
- loading state is per-directory, not global
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
- in-flight dedupe exists for status and `ensureAll()`
- diff data is separately cached and capped with size + count limits
### `useGitHubPrStatusStore.ts`
`useGitHubPrStatusStore` is a centralized PR cache keyed by `directory::branch`.
Core model:
- each entry stores:
- current PR status payload
- loading / error state
- whether initial status was resolved
- refresh timestamps
- watch count
- runtime params
- resolved identity
Important properties:
- `ensureEntry()` initializes a key lazily
- `setParams()` attaches runtime context
- `startWatching()` / `stopWatching()` are for true live PR consumers only
- `refreshTargets()` supports one-shot multi-target bootstrap without turning on live watching
- persisted cache is for page refresh continuity, not for broad background syncing
## Ownership Rules
These rules are important. Breaking them tends to reintroduce idle CPU churn, stale UI, or rerender fanout.
1. No broad `directories` or `entries` subscriptions in normal UI components.
2. No root pollers for Git or PR.
3. No broad idle sweeps across many directories.
4. Prefer store `ensure*` methods over direct runtime API calls from views.
5. Visible consumers should drive refresh. Hidden consumers should not.
6. Header should not depend on PR store.
7. Closed sidebar should not create live PR work.
8. File tree Git status should update only when the file tree is visible.
## Selector Rules
Use leaf selectors.
Good:
- `useGitStatus(directory)`
- `useGitBranches(directory)`
- `useGitBranchLabel(directory)`
- `useGitRepoStatusMap(directories)`
- `usePrVisualSummaryByKeys(keys)`
Bad:
- `useGitStore((state) => state.directories)` in feature components
- `useGitHubPrStatusStore((state) => state.entries)` in feature components
- render-time scans over every PR entry for a single project/group badge
Why this matters:
- Zustand reruns selectors on every `set`
- rerenders are avoided only if the selected result stays referentially stable
- broad subscriptions magnify fanout even when only one directory changed
## Performance Rules
### 1. Preserve references for unaffected entities
If directory `A` changes, directory `B` should keep the same derived reference where possible.
### 2. Keep loading state per entity
Do not add new global `isLoadingWhatever` flags for keyed cache work.
### 3. Avoid hidden work
If a surface is not visible, it should not keep refreshing Git/PR state.
Examples:
- `PullRequestSection` may watch a PR while visible
- `SessionSidebar` may bootstrap missing PR data for expanded visible groups
- hidden sidebar should not watch PRs
### 4. Prefer one-shot event hints over polling
Example already in use:
- successful mutating tools emit a centralized Git refresh hint through `sessionEvents`
- visible `GitView` / `DiffView` consume the hint and refresh current-directory status
This is preferred over background polling.
### 5. Treat `diffStats` carefully
`GitStatus.diffStats` may be omitted by light status fetches.
Rules:
- do not erase richer existing `diffStats` with a lighter payload
- if a UI surface requires per-file `+/-` stats, it must ensure a full enough status payload exists
### 6. Keep diff cache bounded
Diff cache has explicit limits because large repos can otherwise blow up memory.
Do not raise limits casually.
## Refresh Model
### Git
Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible
- explicit Git actions refresh status/branches/log as needed
- successful file-mutating tools can issue a one-shot Git refresh hint
- no root-level background Git polling
### PR
Expected model:
- `PullRequestSection` is the only true live PR watcher
- `SessionSidebar` may do one-shot bootstrap for expanded visible project/worktree groups if PR info is missing
- no live PR work for header
- no background PR sweeps outside visible demand
## Known Intentional Fallbacks
There is still one explicit fallback path worth knowing about:
- `SessionSidebar` may call `checkIsGitRepository(...)` during initial worktree/project discovery when store state is not populated yet
This is currently acceptable as a narrow bootstrap fallback.
Do not widen it into a polling or broad refresh system.
## When Editing These Stores
Before changing store shape or selectors, ask:
1. Is this keyed by the right identity (directory, branch, session, root)?
2. Will this force unrelated consumers to rerender?
3. Should this be visible-demand-driven instead of background-driven?
4. Is there already a store cache for this data?
5. Am I duplicating fetch ownership in a component when it should live in a store action?
## Validation Checklist
After meaningful Git/PR store changes, verify manually:
1. Idle desktop app stays quiet on draft/chat screen.
2. Git view still loads status, branches, log, identity.
3. Diff view still opens the correct file and stays in sync.
4. Worktree sessions still show branch labels in header.
5. Expanded sidebar projects/worktrees can show PR state without requiring prior selection.
6. Hidden surfaces do not reintroduce live background work.
+106 -62
View File
@@ -85,20 +85,12 @@ type GitHubPrStatusStore = {
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise<void>;
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
syncBackgroundTargets: (args: {
targets: PrTrackingTarget[];
github?: RuntimeAPIs['github'];
githubAuthChecked: boolean;
githubConnected: boolean | null;
}) => void;
};
const timers = new Map<string, number>();
const bootstrapTimers = new Map<string, number[]>();
const inFlightBySignature = new Set<string>();
const lastRefreshBySignature = new Map<string, number>();
const backgroundWatchingKeys = new Set<string>();
const createEntry = (): PrStatusEntry => ({
status: null,
isLoading: false,
@@ -607,60 +599,6 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
});
},
syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => {
if (!github || targets.length === 0) {
Array.from(backgroundWatchingKeys).forEach((key) => {
get().stopWatching(key);
backgroundWatchingKeys.delete(key);
});
return;
}
const uniqueTargets = new Map<string, PrTrackingTarget>();
targets.forEach((target) => {
const directory = target.directory.trim();
const branch = target.branch.trim();
if (!directory || !branch) {
return;
}
const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, {
directory,
branch,
remoteName: target.remoteName ?? null,
});
}
});
const nextKeys = new Set(uniqueTargets.keys());
Array.from(backgroundWatchingKeys).forEach((key) => {
if (nextKeys.has(key)) {
return;
}
get().stopWatching(key);
backgroundWatchingKeys.delete(key);
});
uniqueTargets.forEach((target, key) => {
get().ensureEntry(key);
get().setParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: target.remoteName ?? null,
canShow: true,
github,
githubAuthChecked,
githubConnected,
});
if (!backgroundWatchingKeys.has(key)) {
get().startWatching(key);
backgroundWatchingKeys.add(key);
}
});
},
}),
{
name: PR_STATUS_STORAGE_KEY,
@@ -692,3 +630,109 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
},
),
);
export const usePrStatusForDirectoryBranch = (directory: string | null, branch: string | null) => {
return useGitHubPrStatusStore((state) => {
if (!directory || !branch) return null;
const key = getGitHubPrStatusKey(directory, branch);
return state.entries[key] ?? null;
});
};
export type PrVisualSummary = {
number: number;
visualState: string;
prState: string;
draft: boolean;
title: string | null;
url: string | null;
base: string | null;
head: string | null;
checks: { state: string; total: number; success: number; failure: number; pending: number } | null;
canMerge: boolean | null;
mergeableState: string | null;
repo: { owner: string; repo: string } | null;
};
const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | null => {
const pr = status?.pr;
if (!pr) return null;
if (pr.state === 'merged') return 'merged';
if (pr.state === 'closed') return 'closed';
if (pr.draft) return 'draft';
const checksFailed = status?.checks?.state === 'failure';
const ms = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || ms === 'blocked' || ms === 'dirty';
if (checksFailed || notMergeable) return 'blocked';
return 'open';
};
const prVisualPriority = (state: string): number => {
switch (state) {
case 'open': return 5;
case 'blocked': return 4;
case 'draft': return 3;
case 'merged': return 2;
case 'closed': return 1;
default: return 0;
}
};
const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
const vs = derivePrVisualState(entry.status ?? null);
const pr = entry.status?.pr;
if (!vs || !pr?.number) return null;
return {
number: pr.number,
visualState: vs,
prState: pr.state,
draft: Boolean(pr.draft),
title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null,
url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null,
base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null,
head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null,
checks: entry.status?.checks
? { state: entry.status.checks.state, total: entry.status.checks.total, success: entry.status.checks.success, failure: entry.status.checks.failure, pending: entry.status.checks.pending }
: null,
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null,
repo: entry.status?.repo ? { owner: entry.status.repo.owner, repo: entry.status.repo.repo } : null,
};
};
const summarySignature = (s: PrVisualSummary): string =>
`${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`;
let prKeyedCacheSigs = new Map<string, string>();
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
export const usePrVisualSummaryByKeys = (keys: string[]) => {
return useGitHubPrStatusStore((state) => {
// Derive summaries for requested keys only
const nextSigs = new Map<string, string>();
const nextSummaries = new Map<string, PrVisualSummary>();
for (const key of keys) {
const entry = state.entries[key];
if (!entry) continue;
const summary = deriveSummary(entry);
if (!summary) continue;
const sig = summarySignature(summary);
nextSigs.set(key, sig);
nextSummaries.set(key, summary);
}
// Compare with cached signatures
if (nextSigs.size === prKeyedCacheSigs.size) {
let same = true;
for (const [k, sig] of nextSigs) {
if (prKeyedCacheSigs.get(k) !== sig) { same = false; break; }
}
if (same) return prKeyedCacheResult;
}
prKeyedCacheSigs = nextSigs;
prKeyedCacheResult = nextSummaries;
return nextSummaries;
});
};
+210 -153
View File
@@ -1,3 +1,4 @@
import React from 'react';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
@@ -7,19 +8,16 @@ import type {
GitIdentitySummary,
} from '@/lib/api/types';
const GIT_POLL_BASE_INTERVAL = 10000;
const GIT_POLL_MAX_INTERVAL = 30000;
const GIT_POLL_BUSY_BASE_INTERVAL = 15000;
const GIT_POLL_BUSY_MAX_INTERVAL = 40000;
const GIT_POLL_BACKOFF_STEP = 5000;
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
const STATUS_STALE_THRESHOLD = 5_000;
const BRANCHES_STALE_THRESHOLD = 30_000;
const IDENTITY_STALE_THRESHOLD = 60_000;
const DIFF_PREFETCH_MAX_FILES = 25;
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
const DIFF_PREFETCH_CONCURRENCY = 2;
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
const DIFF_PREFETCH_LARGE_FILE_THRESHOLD = 500; // skip prefetch for files with >500 changed lines
const RECENT_DIRECTORIES_LIMIT = 3;
// Diff cache limits to prevent memory bloat with many modified files
const DIFF_CACHE_MAX_ENTRIES = 30;
@@ -36,7 +34,13 @@ interface DirectoryGitState {
lastStatusFetch: number;
lastStatusChange: number;
lastLogFetch: number;
lastBranchesFetch: number;
lastIdentityFetch: number;
logMaxCount: number;
isLoadingStatus: boolean;
isLoadingLog: boolean;
isLoadingBranches: boolean;
isLoadingIdentity: boolean;
}
interface GitStore {
@@ -44,16 +48,6 @@ interface GitStore {
directories: Map<string, DirectoryGitState>;
activeDirectory: string | null;
recentDirectories: string[];
isLoadingStatus: boolean;
isLoadingLog: boolean;
isLoadingBranches: boolean;
isLoadingIdentity: boolean;
pollIntervalId: ReturnType<typeof setTimeout> | null;
currentPollInterval: number;
pollingMode: 'normal' | 'busy';
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
@@ -64,6 +58,9 @@ interface GitStore {
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean; silentIfCached?: boolean }) => Promise<void>;
ensureStatus: (directory: string, git: GitAPI) => Promise<void>;
ensureAll: (directory: string, git: GitAPI) => Promise<void>;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
clearDiffCache: (directory: string) => void;
@@ -72,10 +69,6 @@ interface GitStore {
setLogMaxCount: (directory: string, maxCount: number) => void;
startPolling: (git: GitAPI) => void;
setPollingMode: (mode: 'normal' | 'busy') => void;
stopPolling: () => void;
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
}
@@ -98,6 +91,7 @@ interface GitAPI {
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
const diffFetchGenerationByDirectory = new Map<string, number>();
const inFlightStatusFetchesByDirectory = new Map<string, Promise<boolean>>();
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
const getDiffFetchGeneration = (directory: string): number =>
diffFetchGenerationByDirectory.get(directory) ?? 0;
@@ -129,7 +123,13 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
lastStatusFetch: 0,
lastStatusChange: 0,
lastLogFetch: 0,
lastBranchesFetch: 0,
lastIdentityFetch: 0,
logMaxCount: 25,
isLoadingStatus: false,
isLoadingLog: false,
isLoadingBranches: false,
isLoadingIdentity: false,
});
// LRU eviction helper for diff cache
@@ -275,36 +275,14 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
return changed;
};
const getPollingBounds = (mode: 'normal' | 'busy') => {
if (mode === 'busy') {
return {
base: GIT_POLL_BUSY_BASE_INTERVAL,
max: GIT_POLL_BUSY_MAX_INTERVAL,
};
}
return {
base: GIT_POLL_BASE_INTERVAL,
max: GIT_POLL_MAX_INTERVAL,
};
};
export const useGitStore = create<GitStore>()(
devtools(
(set, get) => ({
directories: new Map(),
activeDirectory: null,
recentDirectories: [],
isLoadingStatus: false,
isLoadingLog: false,
isLoadingBranches: false,
isLoadingIdentity: false,
pollIntervalId: null,
currentPollInterval: GIT_POLL_BASE_INTERVAL,
pollingMode: 'normal',
setActiveDirectory: (directory) => {
const { activeDirectory, directories, recentDirectories } = get();
const { activeDirectory, directories } = get();
if (activeDirectory === directory) return;
if (activeDirectory) {
@@ -314,16 +292,12 @@ export const useGitStore = create<GitStore>()(
bumpDiffFetchGeneration(directory);
}
const nextRecentDirectories = directory
? [directory, ...recentDirectories.filter((entry) => entry !== directory)].slice(0, RECENT_DIRECTORIES_LIMIT)
: recentDirectories;
if (directory && !directories.has(directory)) {
const newDirectories = new Map(directories);
newDirectories.set(directory, createEmptyDirectoryState());
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories, directories: newDirectories });
set({ activeDirectory: directory, directories: newDirectories });
} else {
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories });
set({ activeDirectory: directory });
}
},
@@ -347,7 +321,10 @@ export const useGitStore = create<GitStore>()(
}
if (!silent) {
set({ isLoadingStatus: true });
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingStatus: true });
set({ directories: newDirectories });
}
let statusChanged = false;
@@ -364,15 +341,17 @@ export const useGitStore = create<GitStore>()(
}
if (!isRepo) {
const newDirectories = new Map(directories);
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory) ?? dirState;
newDirectories.set(directory, {
...dirState,
...currentDirState,
isGitRepo: false,
status: null,
isLoadingStatus: false,
lastRepoCheckAt: now,
lastStatusFetch: now,
});
set({ directories: newDirectories, isLoadingStatus: false });
set({ directories: newDirectories });
return false;
}
@@ -439,7 +418,10 @@ export const useGitStore = create<GitStore>()(
console.error('Failed to fetch git status:', error);
} finally {
if (!silent) {
set({ isLoadingStatus: false });
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingStatus: false });
set({ directories: newDirectories });
}
}
@@ -458,18 +440,25 @@ export const useGitStore = create<GitStore>()(
},
fetchBranches: async (directory, git) => {
set({ isLoadingBranches: true });
{
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingBranches: true });
set({ directories: newDirectories });
}
try {
const branches = await git.getGitBranches(directory);
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...dirState, branches });
newDirectories.set(directory, { ...dirState, branches, isLoadingBranches: false, lastBranchesFetch: Date.now() });
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git branches:', error);
} finally {
set({ isLoadingBranches: false });
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingBranches: false });
set({ directories: newDirectories });
}
},
@@ -478,7 +467,12 @@ export const useGitStore = create<GitStore>()(
const dirState = directories.get(directory);
const effectiveMaxCount = maxCount ?? dirState?.logMaxCount ?? 25;
set({ isLoadingLog: true });
{
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingLog: true });
set({ directories: newDirectories });
}
try {
const log = await git.getGitLog(directory, { maxCount: effectiveMaxCount });
@@ -487,30 +481,40 @@ export const useGitStore = create<GitStore>()(
newDirectories.set(directory, {
...currentDirState,
log,
isLoadingLog: false,
lastLogFetch: Date.now(),
logMaxCount: effectiveMaxCount,
});
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git log:', error);
} finally {
set({ isLoadingLog: false });
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingLog: false });
set({ directories: newDirectories });
}
},
fetchIdentity: async (directory, git) => {
set({ isLoadingIdentity: true });
{
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingIdentity: true });
set({ directories: newDirectories });
}
try {
const identity = await git.getCurrentGitIdentity(directory);
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...dirState, identity });
newDirectories.set(directory, { ...dirState, identity, isLoadingIdentity: false, lastIdentityFetch: Date.now() });
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git identity:', error);
} finally {
set({ isLoadingIdentity: false });
const newDirectories = new Map(get().directories);
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...d, isLoadingIdentity: false });
set({ directories: newDirectories });
}
},
@@ -703,100 +707,54 @@ export const useGitStore = create<GitStore>()(
set({ directories: newDirectories });
},
setPollingMode: (mode) => {
const { pollingMode, currentPollInterval } = get();
if (pollingMode === mode) {
ensureStatus: async (directory, git) => {
const dirState = get().directories.get(directory);
const now = Date.now();
if (dirState?.status && now - dirState.lastStatusFetch < STATUS_STALE_THRESHOLD) {
return;
}
await get().fetchStatus(directory, git, { silent: Boolean(dirState?.status) });
},
const bounds = getPollingBounds(mode);
const nextInterval = Math.min(Math.max(currentPollInterval, bounds.base), bounds.max);
ensureAll: (directory, git) => {
const existing = inFlightEnsureAllByDirectory.get(directory);
if (existing) return existing;
set({
pollingMode: mode,
currentPollInterval: nextInterval,
const promise = (async () => {
const dirState = get().directories.get(directory);
const now = Date.now();
const needsFullStatus = !dirState?.status || dirState.status.diffStats === undefined;
if (needsFullStatus || now - (dirState?.lastStatusFetch ?? 0) >= STATUS_STALE_THRESHOLD) {
await get().fetchStatus(directory, git, { silent: Boolean(dirState?.status) });
}
const updatedState = get().directories.get(directory);
if (!updatedState?.isGitRepo) return;
const fetches: Promise<void>[] = [];
if (!updatedState.branches || now - updatedState.lastBranchesFetch >= BRANCHES_STALE_THRESHOLD) {
fetches.push(get().fetchBranches(directory, git));
}
if (!updatedState.log || now - updatedState.lastLogFetch >= LOG_STALE_THRESHOLD) {
fetches.push(get().fetchLog(directory, git));
}
if (!updatedState.identity || now - updatedState.lastIdentityFetch >= IDENTITY_STALE_THRESHOLD) {
fetches.push(get().fetchIdentity(directory, git));
}
if (fetches.length > 0) await Promise.all(fetches);
})();
inFlightEnsureAllByDirectory.set(directory, promise);
promise.finally(() => {
if (inFlightEnsureAllByDirectory.get(directory) === promise) {
inFlightEnsureAllByDirectory.delete(directory);
}
});
},
startPolling: (git) => {
const { pollIntervalId } = get();
if (pollIntervalId) return;
const schedulePoll = () => {
const { currentPollInterval } = get();
const timeoutId = setTimeout(async () => {
// Skip if tab not visible
if (typeof document !== 'undefined' && document.hidden) {
set({ pollIntervalId: schedulePoll() });
return;
}
const { activeDirectory, recentDirectories } = get();
if (!activeDirectory) {
set({ pollIntervalId: schedulePoll() });
return;
}
const pollTargets = [
activeDirectory,
...recentDirectories
.filter((directory) => directory !== activeDirectory)
.slice(0, Math.max(0, RECENT_DIRECTORIES_LIMIT - 1)),
];
let anyStatusChanged = false;
const heavyFollowUps: string[] = [];
for (const targetDirectory of pollTargets) {
const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true, mode: 'light' });
if (statusChanged) {
anyStatusChanged = true;
heavyFollowUps.push(targetDirectory);
if (targetDirectory === activeDirectory) {
await get().fetchLog(activeDirectory, git);
// Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch)
}
}
}
// Light mode detected real changes — follow up with heavy fetch for diffStats
for (const dir of heavyFollowUps) {
get().fetchStatus(dir, git, { silent: true });
}
const bounds = getPollingBounds(get().pollingMode);
if (anyStatusChanged) {
// Reset to base interval on changes
set({ currentPollInterval: bounds.base });
} else {
// Backoff when no changes
const newInterval = Math.min(
currentPollInterval + GIT_POLL_BACKOFF_STEP,
bounds.max
);
set({ currentPollInterval: newInterval });
}
// Schedule next poll
const { pollIntervalId: currentId } = get();
if (currentId !== null) {
set({ pollIntervalId: schedulePoll() });
}
}, currentPollInterval);
return timeoutId;
};
const bounds = getPollingBounds(get().pollingMode);
set({ pollIntervalId: schedulePoll(), currentPollInterval: bounds.base });
},
stopPolling: () => {
const { pollIntervalId } = get();
if (pollIntervalId) {
clearTimeout(pollIntervalId);
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL, pollingMode: 'normal' });
}
return promise;
},
refresh: async (git, options = {}) => {
@@ -850,3 +808,102 @@ export const useGitFileCount = (directory: string | null) => {
return state.directories.get(directory)?.status?.files?.length ?? 0;
});
};
export const useGitBranchLabel = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.status?.current?.trim() ?? null;
});
};
const allBranchesCacheRef = { current: new Map<string, string | null>() };
export const useGitAllBranches = () => {
return useGitStore((state) => {
const prev = allBranchesCacheRef.current;
let same = prev.size === state.directories.size;
if (same) {
for (const [dir, dirState] of state.directories) {
if (prev.get(dir) !== (dirState.status?.current ?? null)) { same = false; break; }
}
}
if (same) return prev;
const result = new Map<string, string | null>();
for (const [dir, dirState] of state.directories) {
result.set(dir, dirState.status?.current ?? null);
}
allBranchesCacheRef.current = result;
return result;
});
};
export const useGitBranchMap = (directories: string[]) => {
const cacheRef = React.useRef<Map<string, string | null>>(new Map());
return useGitStore((state) => {
const prev = cacheRef.current;
let same = prev.size === directories.length;
if (same) {
for (const dir of directories) {
if (prev.get(dir) !== (state.directories.get(dir)?.status?.current ?? null)) { same = false; break; }
}
}
if (same) return prev;
const result = new Map<string, string | null>();
for (const dir of directories) {
result.set(dir, state.directories.get(dir)?.status?.current ?? null);
}
cacheRef.current = result;
return result;
});
};
export const useGitRepoStatusMap = (directories: string[]) => {
const cacheRef = React.useRef<Map<string, { isGitRepo: boolean | null; branch: string | null }>>(new Map());
return useGitStore((state) => {
const prev = cacheRef.current;
let same = prev.size === directories.length;
if (same) {
for (const dir of directories) {
const d = state.directories.get(dir);
const pv = prev.get(dir);
if (!pv || (d?.isGitRepo ?? null) !== pv.isGitRepo || (d?.status?.current ?? null) !== pv.branch) { same = false; break; }
}
}
if (same) return prev;
const result = new Map<string, { isGitRepo: boolean | null; branch: string | null }>();
for (const dir of directories) {
const d = state.directories.get(dir);
result.set(dir, { isGitRepo: d?.isGitRepo ?? null, branch: d?.status?.current ?? null });
}
cacheRef.current = result;
return result;
});
};
export const useGitLoadingStatus = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return false;
return state.directories.get(directory)?.isLoadingStatus ?? false;
});
};
export const useGitLoadingLog = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return false;
return state.directories.get(directory)?.isLoadingLog ?? false;
});
};
export const useGitLoadingBranches = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return false;
return state.directories.get(directory)?.isLoadingBranches ?? false;
});
};
export const useGitLoadingIdentity = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return false;
return state.directories.get(directory)?.isLoadingIdentity ?? false;
});
};