* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)
Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.
* perf(startup): cache directory session list for instant sidebar
Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.
* perf(startup): hold API requests through OpenCode warmup instead of 503
The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.
* perf(startup): surface cached providers/agents in pickers (optimistic readiness)
The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.
* perf(startup): don't abort directory bootstrap on transient phase-1 failure
A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.
* perf(startup): don't bootstrap directories from archived sidebar rows
Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.
* perf(startup): stop empty-session bootstrap retry storm on web/desktop
The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.
* perf(startup): scope provider/agent config to project (worktrees inherit)
Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).
- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
/loadAgents/activateDirectory now key by it, so a worktree reuses one shared
project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
the snapshot key always match (picker stays consistent); the OpenCode working
directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
refresh skips re-fetching config that was just loaded (initializeApp then
activateDirectory for the same project), and to avoid churn on rapid project
switches. Config-change invalidation clears the snapshot, which bypasses the
guard, so freshness never masks a needed refresh.
* fix(sidebar): default archived sessions to hidden to avoid startup flash
useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).
* perf(startup): persist worktree->project mapping to kill cold double-load
The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.
* perf(startup): persist worktree map for instant sidebar + first-launch keying
Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.
* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch
On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.
* perf(startup): cache git branches so the draft branch selector paints instantly
The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
1156 lines
41 KiB
TypeScript
1156 lines
41 KiB
TypeScript
import React from 'react';
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import type {
|
|
GitStatus,
|
|
GitBranch,
|
|
GitLogResponse,
|
|
GitIdentitySummary,
|
|
} from '@/lib/api/types';
|
|
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
|
|
|
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
|
|
|
|
// Diff cache limits to prevent memory bloat with many modified files
|
|
const DIFF_CACHE_MAX_ENTRIES = 30;
|
|
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
|
type GitStatusFetchMode = 'full' | 'light';
|
|
|
|
interface DirectoryGitState {
|
|
isGitRepo: boolean | null;
|
|
status: GitStatus | null;
|
|
branches: GitBranch | null;
|
|
log: GitLogResponse | null;
|
|
identity: GitIdentitySummary | null;
|
|
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
|
indexRevision: number;
|
|
lastRepoCheckAt: number;
|
|
lastStatusFetch: number;
|
|
lastStatusChange: number;
|
|
lastLogFetch: number;
|
|
lastBranchesFetch: number;
|
|
lastIdentityFetch: number;
|
|
logMaxCount: number;
|
|
isLoadingStatus: boolean;
|
|
isLoadingLog: boolean;
|
|
isLoadingBranches: boolean;
|
|
isLoadingIdentity: boolean;
|
|
}
|
|
|
|
interface GitStore {
|
|
|
|
directories: Map<string, DirectoryGitState>;
|
|
|
|
activeDirectory: string | null;
|
|
|
|
setActiveDirectory: (directory: string | null) => void;
|
|
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
|
|
|
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise<boolean>;
|
|
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
|
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
|
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>;
|
|
moveStatusPathsOptimistically: (directory: string, paths: string[], direction: 'stage' | 'unstage') => GitStatus | null;
|
|
restoreStatus: (directory: string, status: GitStatus | null) => void;
|
|
bumpIndexRevision: (directory: string) => 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;
|
|
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
|
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise<void>;
|
|
|
|
setLogMaxCount: (directory: string, maxCount: number) => void;
|
|
|
|
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
|
}
|
|
|
|
interface GitFileDiffResponse {
|
|
original: string;
|
|
modified: string;
|
|
path: string;
|
|
isBinary?: boolean;
|
|
}
|
|
|
|
interface GitAPI {
|
|
checkIsGitRepository: (directory: string) => Promise<boolean>;
|
|
getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise<GitStatus>;
|
|
getGitBranches: (directory: string) => Promise<GitBranch>;
|
|
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
|
|
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
|
|
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
|
|
}
|
|
|
|
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
|
const diffFetchGenerationByDirectory = new Map<string, number>();
|
|
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
|
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
|
|
|
const getStatusFetchKey = (directory: string, mode: GitStatusFetchMode): string => `${mode}:${directory}`;
|
|
|
|
const getDiffFetchGeneration = (directory: string): number =>
|
|
diffFetchGenerationByDirectory.get(directory) ?? 0;
|
|
|
|
const bumpDiffFetchGeneration = (directory: string): number => {
|
|
const next = getDiffFetchGeneration(directory) + 1;
|
|
diffFetchGenerationByDirectory.set(directory, next);
|
|
return next;
|
|
};
|
|
|
|
const getInFlightDiffs = (directory: string): Set<string> => {
|
|
const existing = inFlightDiffFetchesByDirectory.get(directory);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const created = new Set<string>();
|
|
inFlightDiffFetchesByDirectory.set(directory, created);
|
|
return created;
|
|
};
|
|
|
|
const createEmptyDirectoryState = (): DirectoryGitState => ({
|
|
isGitRepo: null,
|
|
status: null,
|
|
branches: null,
|
|
log: null,
|
|
identity: null,
|
|
diffCache: new Map(),
|
|
indexRevision: 0,
|
|
lastRepoCheckAt: 0,
|
|
lastStatusFetch: 0,
|
|
lastStatusChange: 0,
|
|
lastLogFetch: 0,
|
|
lastBranchesFetch: 0,
|
|
lastIdentityFetch: 0,
|
|
logMaxCount: 25,
|
|
isLoadingStatus: false,
|
|
isLoadingLog: false,
|
|
isLoadingBranches: false,
|
|
isLoadingIdentity: false,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persisted branch cache (stale-while-revalidate)
|
|
//
|
|
// `git branch` is slow on cold start and the draft branch selector above the
|
|
// composer is gated behind it — it's the slowest-loading composer element.
|
|
// Cache the per-directory branch list to localStorage and seed the store on
|
|
// init so the selector paints instantly from the last-known branches; a stale
|
|
// refresh runs in the background (see ChatInput's draft-branch effect). Only the
|
|
// branch list is cached — never status/log/diff.
|
|
// ---------------------------------------------------------------------------
|
|
const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache';
|
|
|
|
const readBranchCache = (): Record<string, GitBranch> => {
|
|
try {
|
|
const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw) as Record<string, GitBranch>;
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const writeCachedBranches = (directory: string, branches: GitBranch): void => {
|
|
if (!directory || !branches) return;
|
|
try {
|
|
const cache = readBranchCache();
|
|
cache[directory] = branches;
|
|
getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache));
|
|
} catch {
|
|
// quota / serialization — ignore; live fetch still refreshes the store
|
|
}
|
|
};
|
|
|
|
const seedDirectoriesFromBranchCache = (): Map<string, DirectoryGitState> => {
|
|
const directories = new Map<string, DirectoryGitState>();
|
|
const cache = readBranchCache();
|
|
for (const [directory, branches] of Object.entries(cache)) {
|
|
if (!directory || !branches || !Array.isArray(branches.all)) continue;
|
|
// A cached branch list implies the directory was a git repo. Seed isGitRepo
|
|
// so the selector's gate passes immediately; lastBranchesFetch stays 0 so the
|
|
// ChatInput effect treats it as stale and refreshes in the background.
|
|
directories.set(directory, { ...createEmptyDirectoryState(), isGitRepo: true, branches });
|
|
}
|
|
return directories;
|
|
};
|
|
|
|
// LRU eviction helper for diff cache
|
|
const evictDiffCacheIfNeeded = (
|
|
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
|
maxEntries: number = DIFF_CACHE_MAX_ENTRIES,
|
|
maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES
|
|
): Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }> => {
|
|
// Calculate total size
|
|
let totalSize = 0;
|
|
for (const entry of diffCache.values()) {
|
|
totalSize += (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
|
|
}
|
|
|
|
// If within limits, return as-is
|
|
if (diffCache.size <= maxEntries && totalSize <= maxTotalSize) {
|
|
return diffCache;
|
|
}
|
|
|
|
// Sort entries by fetchedAt (oldest first) for LRU eviction
|
|
const entries = Array.from(diffCache.entries())
|
|
.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
|
|
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>();
|
|
let newTotalSize = 0;
|
|
|
|
// Keep entries from newest to oldest until limits are reached
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const [path, entry] = entries[i];
|
|
const entrySize = (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
|
|
|
|
if (newCache.size >= maxEntries) break;
|
|
if (newTotalSize + entrySize > maxTotalSize && newCache.size > 0) continue;
|
|
|
|
newCache.set(path, entry);
|
|
newTotalSize += entrySize;
|
|
}
|
|
|
|
return newCache;
|
|
};
|
|
|
|
const haveDiffStatsChanged = (
|
|
previous?: GitStatus['diffStats'],
|
|
next?: GitStatus['diffStats']
|
|
): boolean => {
|
|
if (!previous && !next) return false;
|
|
if (!previous || !next) return true;
|
|
|
|
const paths = new Set([...Object.keys(previous), ...Object.keys(next)]);
|
|
for (const path of paths) {
|
|
const prevEntry = previous[path];
|
|
const nextEntry = next[path];
|
|
|
|
if (!prevEntry && !nextEntry) continue;
|
|
if (!prevEntry || !nextEntry) return true;
|
|
if (
|
|
prevEntry.insertions !== nextEntry.insertions ||
|
|
prevEntry.deletions !== nextEntry.deletions
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const haveRemoteComparisonChanged = (
|
|
previous?: GitStatus['upstreamComparison'],
|
|
next?: GitStatus['upstreamComparison']
|
|
): boolean => {
|
|
if (!previous && !next) return false;
|
|
if (!previous || !next) return true;
|
|
|
|
return (
|
|
previous.remote !== next.remote
|
|
|| previous.branch !== next.branch
|
|
|| previous.ahead !== next.ahead
|
|
|| previous.behind !== next.behind
|
|
);
|
|
};
|
|
|
|
const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
|
|
if (!oldStatus && !newStatus) return false;
|
|
if (!oldStatus || !newStatus) return true;
|
|
|
|
const oldFiles = oldStatus.files ?? [];
|
|
const newFiles = newStatus.files ?? [];
|
|
|
|
if (oldFiles.length !== newFiles.length) return true;
|
|
if (oldStatus.ahead !== newStatus.ahead) return true;
|
|
if (oldStatus.behind !== newStatus.behind) return true;
|
|
if (oldStatus.current !== newStatus.current) return true;
|
|
if (oldStatus.tracking !== newStatus.tracking) return true;
|
|
if (oldStatus.isClean !== newStatus.isClean) return true;
|
|
if (
|
|
newStatus.upstreamComparison !== undefined
|
|
&& haveRemoteComparisonChanged(oldStatus.upstreamComparison, newStatus.upstreamComparison)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const oldPaths = new Set(oldFiles.map(f => `${f.path}:${f.index}:${f.working_dir}`));
|
|
for (const file of newFiles) {
|
|
if (!oldPaths.has(`${file.path}:${file.index}:${file.working_dir}`)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Skip diffStats comparison when light mode omits them (undefined)
|
|
if (newStatus.diffStats !== undefined && haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus | null): Set<string> => {
|
|
const changed = new Set<string>();
|
|
if (!newStatus) return changed;
|
|
|
|
const oldFiles = oldStatus?.files ?? [];
|
|
const newFiles = newStatus.files ?? [];
|
|
|
|
const oldFileMap = new Map(oldFiles.map((f) => [f.path, f] as const));
|
|
const newFileMap = new Map(newFiles.map((f) => [f.path, f] as const));
|
|
|
|
const allFilePaths = new Set<string>([...oldFileMap.keys(), ...newFileMap.keys()]);
|
|
for (const filePath of allFilePaths) {
|
|
const oldFile = oldFileMap.get(filePath);
|
|
const newFile = newFileMap.get(filePath);
|
|
|
|
// Added/removed/renamed
|
|
if (!oldFile || !newFile) {
|
|
changed.add(filePath);
|
|
continue;
|
|
}
|
|
|
|
// Index/worktree state changed (indicates actual content/state changed)
|
|
if (oldFile.index !== newFile.index || oldFile.working_dir !== newFile.working_dir) {
|
|
changed.add(filePath);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Only compare diffStats when light mode provides them (non-undefined)
|
|
if (newStatus.diffStats !== undefined) {
|
|
const oldStats = oldStatus?.diffStats ?? {};
|
|
const newStats = newStatus.diffStats ?? {};
|
|
const allStatPaths = new Set<string>([...Object.keys(oldStats), ...Object.keys(newStats)]);
|
|
|
|
for (const filePath of allStatPaths) {
|
|
const oldEntry = oldStats[filePath];
|
|
const newEntry = newStats[filePath];
|
|
|
|
if (!oldEntry || !newEntry) {
|
|
changed.add(filePath);
|
|
continue;
|
|
}
|
|
|
|
if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) {
|
|
changed.add(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return changed;
|
|
};
|
|
|
|
const hasIndexStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
|
|
if (!oldStatus && !newStatus) return false;
|
|
if (!oldStatus || !newStatus) return true;
|
|
|
|
const oldFiles = oldStatus.files ?? [];
|
|
const newFiles = newStatus.files ?? [];
|
|
const normalizeIndexStatus = (value?: string | null): string => {
|
|
const trimmed = value?.trim() ?? '';
|
|
return trimmed === '?' ? '' : trimmed;
|
|
};
|
|
|
|
const oldIndexByPath = new Map(oldFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
|
|
const newIndexByPath = new Map(newFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
|
|
const paths = new Set<string>([...oldIndexByPath.keys(), ...newIndexByPath.keys()]);
|
|
|
|
for (const path of paths) {
|
|
if ((oldIndexByPath.get(path) ?? '') !== (newIndexByPath.get(path) ?? '')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const isBlankStatusCode = (value?: string | null): boolean => !value || value.trim().length === 0;
|
|
const isConflictStatusCode = (value?: string | null): boolean => (value || '').trim() === 'U';
|
|
|
|
const toStagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
|
|
const index = (file.index || '').trim();
|
|
const workingDir = (file.working_dir || '').trim();
|
|
|
|
if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
|
|
return file;
|
|
}
|
|
|
|
const nextIndex = index === '?' || workingDir === '?'
|
|
? 'A'
|
|
: index || workingDir || ' ';
|
|
|
|
return {
|
|
...file,
|
|
index: nextIndex,
|
|
working_dir: ' ',
|
|
};
|
|
};
|
|
|
|
const toUnstagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
|
|
const index = (file.index || '').trim();
|
|
const workingDir = (file.working_dir || '').trim();
|
|
|
|
if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
|
|
return file;
|
|
}
|
|
|
|
const nextWorkingDir = workingDir || (index === 'A' || index === '?' ? '?' : index) || ' ';
|
|
|
|
return {
|
|
...file,
|
|
index: ' ',
|
|
working_dir: nextWorkingDir,
|
|
};
|
|
};
|
|
|
|
const isCleanStatusFile = (file: GitStatus['files'][number]): boolean =>
|
|
isBlankStatusCode(file.index) && isBlankStatusCode(file.working_dir);
|
|
|
|
export const useGitStore = create<GitStore>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
directories: seedDirectoriesFromBranchCache(),
|
|
activeDirectory: null,
|
|
|
|
setActiveDirectory: (directory) => {
|
|
const { activeDirectory, directories } = get();
|
|
if (activeDirectory === directory) return;
|
|
|
|
if (activeDirectory) {
|
|
bumpDiffFetchGeneration(activeDirectory);
|
|
}
|
|
if (directory) {
|
|
bumpDiffFetchGeneration(directory);
|
|
}
|
|
|
|
if (directory && !directories.has(directory)) {
|
|
const newDirectories = new Map(directories);
|
|
newDirectories.set(directory, createEmptyDirectoryState());
|
|
set({ activeDirectory: directory, directories: newDirectories });
|
|
} else {
|
|
set({ activeDirectory: directory });
|
|
}
|
|
},
|
|
|
|
getDirectoryState: (directory) => {
|
|
return get().directories.get(directory) ?? null;
|
|
},
|
|
|
|
fetchStatus: async (directory, git, options = {}) => {
|
|
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
|
|
const statusFetchKey = getStatusFetchKey(directory, statusFetchMode);
|
|
const existing = inFlightStatusFetches.get(statusFetchKey)
|
|
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(directory, 'full')) : undefined);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const fetchPromise = (async () => {
|
|
const { silent = false } = options;
|
|
const { directories } = get();
|
|
let dirState = directories.get(directory);
|
|
|
|
if (!dirState) {
|
|
dirState = createEmptyDirectoryState();
|
|
}
|
|
|
|
if (!silent) {
|
|
const newDirectories = new Map(get().directories);
|
|
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, { ...d, isLoadingStatus: true });
|
|
set({ directories: newDirectories });
|
|
}
|
|
|
|
let statusChanged = false;
|
|
|
|
try {
|
|
const now = Date.now();
|
|
const shouldProbeRepository =
|
|
dirState.isGitRepo !== true ||
|
|
now - (dirState.lastRepoCheckAt || 0) > REPO_CHECK_STALE_THRESHOLD;
|
|
|
|
let isRepo = dirState.isGitRepo === true;
|
|
if (shouldProbeRepository) {
|
|
isRepo = await git.checkIsGitRepository(directory);
|
|
}
|
|
|
|
if (!isRepo) {
|
|
const newDirectories = new Map(get().directories);
|
|
const currentDirState = newDirectories.get(directory) ?? dirState;
|
|
newDirectories.set(directory, {
|
|
...currentDirState,
|
|
isGitRepo: false,
|
|
status: null,
|
|
isLoadingStatus: false,
|
|
lastRepoCheckAt: now,
|
|
lastStatusFetch: now,
|
|
});
|
|
set({ directories: newDirectories });
|
|
return false;
|
|
}
|
|
|
|
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
|
|
|
|
if (hasStatusChanged(dirState.status, newStatus)) {
|
|
statusChanged = true;
|
|
const newDirectories = new Map(get().directories);
|
|
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
|
|
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
|
const indexStatusChanged = hasIndexStatusChanged(currentDirState.status, newStatus);
|
|
|
|
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
|
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
|
|
|
const nextDiffCache = new Map(currentDirState.diffCache);
|
|
|
|
// Drop cache for removed files
|
|
for (const oldPath of oldPaths) {
|
|
if (!newPaths.has(oldPath)) {
|
|
nextDiffCache.delete(oldPath);
|
|
}
|
|
}
|
|
|
|
// Drop cache for files whose state/content changed
|
|
for (const filePath of changedPaths) {
|
|
nextDiffCache.delete(filePath);
|
|
}
|
|
|
|
const hasFileContentChange = changedPaths.size > 0;
|
|
if (hasFileContentChange) {
|
|
bumpDiffFetchGeneration(directory);
|
|
}
|
|
|
|
// Preserve diffStats from previous status when light mode returns none
|
|
const mergedStatus = {
|
|
...newStatus,
|
|
diffStats:
|
|
newStatus.diffStats === undefined && currentDirState.status?.diffStats !== undefined
|
|
? currentDirState.status.diffStats
|
|
: newStatus.diffStats,
|
|
upstreamComparison:
|
|
newStatus.upstreamComparison === undefined
|
|
? currentDirState.status?.upstreamComparison
|
|
: newStatus.upstreamComparison,
|
|
};
|
|
|
|
newDirectories.set(directory, {
|
|
...currentDirState,
|
|
isGitRepo: true,
|
|
status: mergedStatus,
|
|
diffCache: nextDiffCache,
|
|
indexRevision: indexStatusChanged ? currentDirState.indexRevision + 1 : currentDirState.indexRevision,
|
|
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
|
lastStatusFetch: Date.now(),
|
|
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
|
});
|
|
set({ directories: newDirectories });
|
|
} else {
|
|
|
|
const newDirectories = new Map(get().directories);
|
|
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, {
|
|
...currentDirState,
|
|
isGitRepo: true,
|
|
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
|
lastStatusFetch: Date.now(),
|
|
lastStatusChange: currentDirState.lastStatusChange,
|
|
});
|
|
set({ directories: newDirectories });
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch git status:', error);
|
|
} finally {
|
|
if (!silent) {
|
|
const newDirectories = new Map(get().directories);
|
|
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, { ...d, isLoadingStatus: false });
|
|
set({ directories: newDirectories });
|
|
}
|
|
}
|
|
|
|
return statusChanged;
|
|
})();
|
|
|
|
inFlightStatusFetches.set(statusFetchKey, fetchPromise);
|
|
|
|
try {
|
|
return await fetchPromise;
|
|
} finally {
|
|
if (inFlightStatusFetches.get(statusFetchKey) === fetchPromise) {
|
|
inFlightStatusFetches.delete(statusFetchKey);
|
|
}
|
|
}
|
|
},
|
|
|
|
moveStatusPathsOptimistically: (directory, paths, direction) => {
|
|
const normalizedPaths = new Set(paths.map((path) => path.trim()).filter(Boolean));
|
|
if (normalizedPaths.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
const { directories } = get();
|
|
const dirState = directories.get(directory);
|
|
const previousStatus = dirState?.status ?? null;
|
|
if (!dirState || !previousStatus) {
|
|
return previousStatus;
|
|
}
|
|
|
|
let didChange = false;
|
|
const nextFiles: GitStatus['files'] = [];
|
|
|
|
for (const file of previousStatus.files) {
|
|
if (!normalizedPaths.has(file.path)) {
|
|
nextFiles.push(file);
|
|
continue;
|
|
}
|
|
|
|
const nextFile = direction === 'stage'
|
|
? toStagedStatusFile(file)
|
|
: toUnstagedStatusFile(file);
|
|
|
|
if (nextFile !== file) {
|
|
didChange = true;
|
|
}
|
|
|
|
if (!isCleanStatusFile(nextFile)) {
|
|
nextFiles.push(nextFile);
|
|
} else {
|
|
didChange = true;
|
|
}
|
|
}
|
|
|
|
if (!didChange) {
|
|
return previousStatus;
|
|
}
|
|
|
|
const nextDirectories = new Map(directories);
|
|
nextDirectories.set(directory, {
|
|
...dirState,
|
|
status: {
|
|
...previousStatus,
|
|
files: nextFiles,
|
|
isClean: nextFiles.length === 0,
|
|
},
|
|
indexRevision: dirState.indexRevision + 1,
|
|
lastStatusChange: Date.now(),
|
|
});
|
|
set({ directories: nextDirectories });
|
|
|
|
return previousStatus;
|
|
},
|
|
|
|
restoreStatus: (directory, status) => {
|
|
const { directories } = get();
|
|
const dirState = directories.get(directory);
|
|
if (!dirState) {
|
|
return;
|
|
}
|
|
|
|
const nextDirectories = new Map(directories);
|
|
nextDirectories.set(directory, {
|
|
...dirState,
|
|
status,
|
|
indexRevision: dirState.indexRevision + 1,
|
|
lastStatusChange: Date.now(),
|
|
});
|
|
set({ directories: nextDirectories });
|
|
},
|
|
|
|
bumpIndexRevision: (directory) => {
|
|
const { directories } = get();
|
|
const dirState = directories.get(directory);
|
|
if (!dirState) {
|
|
return;
|
|
}
|
|
|
|
const nextDirectories = new Map(directories);
|
|
nextDirectories.set(directory, {
|
|
...dirState,
|
|
indexRevision: dirState.indexRevision + 1,
|
|
});
|
|
set({ directories: nextDirectories });
|
|
},
|
|
|
|
fetchBranches: async (directory, git) => {
|
|
{
|
|
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, isLoadingBranches: false, lastBranchesFetch: Date.now() });
|
|
set({ directories: newDirectories });
|
|
writeCachedBranches(directory, branches);
|
|
} catch (error) {
|
|
console.error('Failed to fetch git branches:', error);
|
|
const newDirectories = new Map(get().directories);
|
|
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, { ...d, isLoadingBranches: false });
|
|
set({ directories: newDirectories });
|
|
}
|
|
},
|
|
|
|
fetchLog: async (directory, git, maxCount) => {
|
|
const { directories } = get();
|
|
const dirState = directories.get(directory);
|
|
const effectiveMaxCount = maxCount ?? dirState?.logMaxCount ?? 25;
|
|
|
|
{
|
|
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 });
|
|
const newDirectories = new Map(get().directories);
|
|
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
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);
|
|
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) => {
|
|
{
|
|
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, isLoadingIdentity: false, lastIdentityFetch: Date.now() });
|
|
set({ directories: newDirectories });
|
|
} catch (error) {
|
|
console.error('Failed to fetch git identity:', error);
|
|
const newDirectories = new Map(get().directories);
|
|
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, { ...d, isLoadingIdentity: false });
|
|
set({ directories: newDirectories });
|
|
}
|
|
},
|
|
|
|
fetchAll: async (directory, git, options = {}) => {
|
|
const { directories } = get();
|
|
let dirState = directories.get(directory);
|
|
|
|
if (!dirState) {
|
|
dirState = createEmptyDirectoryState();
|
|
const newDirectories = new Map(directories);
|
|
newDirectories.set(directory, dirState);
|
|
set({ directories: newDirectories });
|
|
}
|
|
|
|
const { force = false, silentIfCached = false } = options;
|
|
const now = Date.now();
|
|
|
|
await get().fetchStatus(directory, git, {
|
|
silent: silentIfCached && Boolean(dirState?.status),
|
|
});
|
|
|
|
const updatedDirState = get().directories.get(directory);
|
|
if (!updatedDirState?.isGitRepo) return;
|
|
|
|
await get().fetchBranches(directory, git);
|
|
|
|
const logAge = now - (updatedDirState.lastLogFetch || 0);
|
|
if (force || logAge > LOG_STALE_THRESHOLD || !updatedDirState.log) {
|
|
await get().fetchLog(directory, git);
|
|
}
|
|
|
|
await get().fetchIdentity(directory, git);
|
|
|
|
// Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch)
|
|
|
|
},
|
|
|
|
getDiff: (directory, filePath) => {
|
|
const dirState = get().directories.get(directory);
|
|
return dirState?.diffCache.get(filePath) ?? null;
|
|
},
|
|
|
|
setDiff: (directory, filePath, diff) => {
|
|
const newDirectories = new Map(get().directories);
|
|
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
const newDiffCache = new Map(dirState.diffCache);
|
|
newDiffCache.set(filePath, { ...diff, fetchedAt: Date.now() });
|
|
// Apply LRU eviction to prevent memory bloat
|
|
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
|
|
newDirectories.set(directory, { ...dirState, diffCache: evictedCache });
|
|
set({ directories: newDirectories });
|
|
},
|
|
|
|
clearDiffCache: (directory) => {
|
|
bumpDiffFetchGeneration(directory);
|
|
const newDirectories = new Map(get().directories);
|
|
const dirState = newDirectories.get(directory);
|
|
if (dirState) {
|
|
newDirectories.set(directory, { ...dirState, diffCache: new Map() });
|
|
set({ directories: newDirectories });
|
|
}
|
|
},
|
|
|
|
fetchAllDiffs: async (directory, git) => {
|
|
const dirState = get().directories.get(directory);
|
|
if (!dirState?.status?.files || dirState.status.files.length === 0) return;
|
|
|
|
const limitedFilesToFetch = dirState.status.files
|
|
.map((file) => file.path)
|
|
.slice(0, DIFF_PREFETCH_MAX_FILES);
|
|
await get().prefetchDiffs(directory, git, limitedFilesToFetch, { maxFiles: DIFF_PREFETCH_MAX_FILES });
|
|
},
|
|
|
|
prefetchDiffs: async (directory, git, filePaths, options = {}) => {
|
|
const dirState = get().directories.get(directory);
|
|
if (!dirState?.status?.files || dirState.status.files.length === 0 || filePaths.length === 0) return;
|
|
|
|
const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options;
|
|
const availablePaths = new Set(dirState.status.files.map((file) => file.path));
|
|
const diffStats = dirState.status.diffStats;
|
|
const inFlight = getInFlightDiffs(directory);
|
|
|
|
const dedupedPaths: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const filePath of filePaths) {
|
|
if (!filePath || seen.has(filePath)) {
|
|
continue;
|
|
}
|
|
seen.add(filePath);
|
|
if (!availablePaths.has(filePath)) {
|
|
continue;
|
|
}
|
|
if (dirState.diffCache.has(filePath)) {
|
|
continue;
|
|
}
|
|
if (inFlight.has(filePath)) {
|
|
continue;
|
|
}
|
|
// Skip large files during prefetch — they'll be fetched on-demand when user clicks
|
|
const stats = diffStats?.[filePath];
|
|
if (stats && (stats.insertions + stats.deletions) > DIFF_PREFETCH_LARGE_FILE_THRESHOLD) {
|
|
continue;
|
|
}
|
|
dedupedPaths.push(filePath);
|
|
}
|
|
|
|
const limitedFilePaths = dedupedPaths.slice(0, Math.max(1, maxFiles));
|
|
if (limitedFilePaths.length === 0) return;
|
|
|
|
const generation = getDiffFetchGeneration(directory);
|
|
|
|
if (typeof document !== 'undefined' && document.hidden) {
|
|
return;
|
|
}
|
|
|
|
limitedFilePaths.forEach((path) => inFlight.add(path));
|
|
|
|
let nextIndex = 0;
|
|
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
|
|
|
|
const takeNext = () => {
|
|
const current = nextIndex;
|
|
nextIndex += 1;
|
|
return current < limitedFilePaths.length ? limitedFilePaths[current] : null;
|
|
};
|
|
|
|
const fetchWithTimeout = async (filePath: string) => {
|
|
const fetchPromise = git.getGitFileDiff(directory, { path: filePath });
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error(`Timed out after ${DIFF_PREFETCH_TIMEOUT_MS}ms`)), DIFF_PREFETCH_TIMEOUT_MS);
|
|
});
|
|
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
|
return {
|
|
path: filePath,
|
|
diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary },
|
|
};
|
|
};
|
|
|
|
const worker = async () => {
|
|
for (;;) {
|
|
if (generation !== getDiffFetchGeneration(directory)) {
|
|
return;
|
|
}
|
|
const next = takeNext();
|
|
if (!next) return;
|
|
try {
|
|
results.push(await fetchWithTimeout(next));
|
|
} catch {
|
|
// Ignore individual failures/timeouts during prefetch.
|
|
} finally {
|
|
inFlight.delete(next);
|
|
}
|
|
}
|
|
};
|
|
|
|
const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilePaths.length);
|
|
await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
|
|
|
|
limitedFilePaths.forEach((path) => inFlight.delete(path));
|
|
|
|
if (generation !== getDiffFetchGeneration(directory)) {
|
|
return;
|
|
}
|
|
|
|
// Update diff cache with results
|
|
const newDirectories = new Map(get().directories);
|
|
const currentDirState = newDirectories.get(directory);
|
|
if (!currentDirState) return;
|
|
|
|
const newDiffCache = new Map(currentDirState.diffCache);
|
|
const now = Date.now();
|
|
|
|
results.forEach((result) => {
|
|
newDiffCache.set(result.path, {
|
|
...result.diff,
|
|
fetchedAt: now
|
|
});
|
|
});
|
|
|
|
// Apply LRU eviction to prevent memory bloat
|
|
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
|
|
newDirectories.set(directory, { ...currentDirState, diffCache: evictedCache });
|
|
set({ directories: newDirectories });
|
|
},
|
|
|
|
setLogMaxCount: (directory, maxCount) => {
|
|
const newDirectories = new Map(get().directories);
|
|
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
|
newDirectories.set(directory, { ...dirState, logMaxCount: maxCount });
|
|
set({ directories: newDirectories });
|
|
},
|
|
|
|
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) });
|
|
},
|
|
|
|
ensureAll: (directory, git) => {
|
|
const existing = inFlightEnsureAllByDirectory.get(directory);
|
|
if (existing) return existing;
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
return promise;
|
|
},
|
|
|
|
refresh: async (git, options = {}) => {
|
|
const { activeDirectory } = get();
|
|
if (!activeDirectory) return;
|
|
await get().fetchAll(activeDirectory, git, options);
|
|
},
|
|
}),
|
|
{ name: 'git-store' }
|
|
)
|
|
);
|
|
|
|
export const useGitStatus = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return null;
|
|
return state.directories.get(directory)?.status ?? null;
|
|
});
|
|
};
|
|
|
|
export const useGitBranches = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return null;
|
|
return state.directories.get(directory)?.branches ?? null;
|
|
});
|
|
};
|
|
|
|
export const useGitLog = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return null;
|
|
return state.directories.get(directory)?.log ?? null;
|
|
});
|
|
};
|
|
|
|
export const useGitIdentity = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return null;
|
|
return state.directories.get(directory)?.identity ?? null;
|
|
});
|
|
};
|
|
|
|
export const useIsGitRepo = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return null;
|
|
return state.directories.get(directory)?.isGitRepo ?? null;
|
|
});
|
|
};
|
|
|
|
export const useGitFileCount = (directory: string | null) => {
|
|
return useGitStore((state) => {
|
|
if (!directory) return 0;
|
|
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;
|
|
});
|
|
};
|