refactor: centralize git and pr store refresh
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user