import React from 'react'; import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { GitStatus, GitBranch, GitLogResponse, GitIdentitySummary, } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; 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 const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200; type GitStatusFetchMode = 'full' | 'light'; interface DirectoryGitState { isGitRepo: boolean | null; status: GitStatus | null; branches: GitBranch | null; log: GitLogResponse | null; identity: GitIdentitySummary | null; diffCache: Map; 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 { runtimeKey: string; directories: Map; activeDirectory: string | null; setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean; silentIfCached?: boolean }) => Promise; ensureStatus: (directory: string, git: GitAPI) => Promise; ensureAll: (directory: string, git: GitAPI) => Promise; 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 }, expectedRuntimeKey?: string) => void; clearDiffCache: (directory: string, filePaths?: string[]) => void; fetchAllDiffs: (directory: string, git: GitAPI) => Promise; prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise; setLogMaxCount: (directory: string, maxCount: number) => void; refresh: (git: GitAPI, options?: { force?: boolean }) => Promise; resetForRuntimeSwitch: (runtimeKey: string) => void; } interface GitFileDiffResponse { original: string; modified: string; path: string; isBinary?: boolean; } interface GitAPI { checkIsGitRepository: (directory: string) => Promise; getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise; getGitBranches: (directory: string) => Promise; getGitLog: (directory: string, options?: { maxCount?: number }) => Promise; getCurrentGitIdentity: (directory: string) => Promise; getGitFileDiff: (directory: string, options: { path: string }) => Promise; } const inFlightDiffFetchesByDirectory = new Map>(); const diffFetchGenerationByDirectory = new Map(); const inFlightStatusFetches = new Map>(); const inFlightEnsureAllByDirectory = new Map>(); const requestGenerationByChannel = new Map(); const statusMutationRevisionByDirectory = new Map(); let gitRuntimeGeneration = 0; let activeGitRuntimeKey = getRuntimeKey(); const runtimeDirectoryKey = (runtimeKey: string, directory: string) => JSON.stringify([runtimeKey, directory]); const getStatusFetchKey = (runtimeKey: string, directory: string, mode: GitStatusFetchMode): string => JSON.stringify([runtimeKey, directory, mode]); const channelKey = (runtimeKey: string, directory: string, channel: string) => JSON.stringify([runtimeKey, directory, channel]); type GitRequestToken = { runtimeKey: string; runtimeGeneration: number; channelKey: string; requestGeneration: number; statusMutationRevision?: number; }; const startRequest = (directory: string, channel: string, includeStatusMutation = false): GitRequestToken => { const runtimeKey = getRuntimeKey(); const key = channelKey(runtimeKey, directory, channel); const requestGeneration = (requestGenerationByChannel.get(key) ?? 0) + 1; requestGenerationByChannel.set(key, requestGeneration); return { runtimeKey, runtimeGeneration: gitRuntimeGeneration, channelKey: key, requestGeneration, ...(includeStatusMutation ? { statusMutationRevision: statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0 } : {}), }; }; const isRequestCurrent = (token: GitRequestToken, directory: string): boolean => ( token.runtimeKey === getRuntimeKey() && token.runtimeKey === activeGitRuntimeKey && token.runtimeGeneration === gitRuntimeGeneration && requestGenerationByChannel.get(token.channelKey) === token.requestGeneration && (token.statusMutationRevision === undefined || token.statusMutationRevision === (statusMutationRevisionByDirectory.get(runtimeDirectoryKey(token.runtimeKey, directory)) ?? 0)) ); const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void => { const key = runtimeDirectoryKey(runtimeKey, directory); statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1); }; const getDiffFetchGeneration = (directory: string): number => diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0; const bumpDiffFetchGeneration = (directory: string): number => { const next = getDiffFetchGeneration(directory) + 1; diffFetchGenerationByDirectory.set(runtimeDirectoryKey(getRuntimeKey(), directory), next); return next; }; const getInFlightDiffs = (directory: string): Set => { const key = runtimeDirectoryKey(getRuntimeKey(), directory); const existing = inFlightDiffFetchesByDirectory.get(key); if (existing) { return existing; } const created = new Set(); inFlightDiffFetchesByDirectory.set(key, 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 GIT_BRANCH_CACHE_V2_KEY = 'oc.gitBranchCache.v2'; const MAX_BRANCH_CACHE_RUNTIMES = 8; const MAX_BRANCH_CACHE_DIRECTORIES = 50; type BranchCacheEnvelope = { version: 2; legacyClaimed: boolean; runtimes: Record }>; }; const emptyBranchCache = (): BranchCacheEnvelope => ({ version: 2, legacyClaimed: false, runtimes: {} }); const readBranchCacheEnvelope = (runtimeKey: string): BranchCacheEnvelope => { try { const storage = getDeferredSafeStorage(); const raw = storage.getItem(GIT_BRANCH_CACHE_V2_KEY); const parsed = raw ? JSON.parse(raw) as Partial : emptyBranchCache(); const envelope: BranchCacheEnvelope = parsed?.version === 2 && parsed.runtimes && typeof parsed.runtimes === 'object' ? { version: 2, legacyClaimed: Boolean(parsed.legacyClaimed), runtimes: parsed.runtimes } : emptyBranchCache(); if (!envelope.legacyClaimed) { const legacyRaw = storage.getItem(GIT_BRANCH_CACHE_KEY); if (legacyRaw) { const legacy = JSON.parse(legacyRaw) as Record; const directories: BranchCacheEnvelope['runtimes'][string]['directories'] = {}; for (const [directory, branches] of Object.entries(legacy ?? {})) { if (directory && branches && Array.isArray(branches.all)) directories[directory] = { branches, updatedAt: 0 }; } if (Object.keys(directories).length > 0) envelope.runtimes[runtimeKey] = { updatedAt: 0, directories }; } envelope.legacyClaimed = true; const serialized = JSON.stringify(envelope); storage.setItem(GIT_BRANCH_CACHE_V2_KEY, serialized); if (storage.getItem(GIT_BRANCH_CACHE_V2_KEY) === serialized) storage.removeItem(GIT_BRANCH_CACHE_KEY); } return envelope; } catch { return emptyBranchCache(); } }; const writeCachedBranches = (runtimeKey: string, directory: string, branches: GitBranch): void => { if (!directory || !branches) return; try { const envelope = readBranchCacheEnvelope(runtimeKey); const now = Date.now(); const current = envelope.runtimes[runtimeKey]?.directories ?? {}; const directories = { ...current, [directory]: { branches, updatedAt: now } }; const boundedDirectories = Object.fromEntries( Object.entries(directories).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_BRANCH_CACHE_DIRECTORIES), ); envelope.runtimes[runtimeKey] = { updatedAt: now, directories: boundedDirectories }; envelope.runtimes = Object.fromEntries( Object.entries(envelope.runtimes).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_BRANCH_CACHE_RUNTIMES), ); getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_V2_KEY, JSON.stringify(envelope)); } catch { // quota / serialization — ignore; live fetch still refreshes the store } }; const seedDirectoriesFromBranchCache = (runtimeKey: string): Map => { const directories = new Map(); const cache = readBranchCacheEnvelope(runtimeKey).runtimes[runtimeKey]?.directories ?? {}; for (const [directory, entry] of Object.entries(cache)) { const branches = entry.branches; 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, maxEntries: number = DIFF_CACHE_MAX_ENTRIES, maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES ): Map => { // Calculate total size let totalSize = 0; for (const entry of diffCache.values()) { totalSize += new TextEncoder().encode(entry.original ?? '').byteLength + new TextEncoder().encode(entry.modified ?? '').byteLength; } // 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(); 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 = new TextEncoder().encode(entry.original ?? '').byteLength + new TextEncoder().encode(entry.modified ?? '').byteLength; if (newCache.size >= maxEntries) break; if (newTotalSize + entrySize > maxTotalSize) continue; newCache.set(path, entry); newTotalSize += entrySize; } return newCache; }; const diffEntrySize = (entry: { original: string; modified: string }): number => { const encoder = new TextEncoder(); return encoder.encode(entry.original ?? '').byteLength + encoder.encode(entry.modified ?? '').byteLength; }; const evictGlobalDiffCachesIfNeeded = (directories: Map): Map => { const entries: Array<{ directory: string; path: string; fetchedAt: number; size: number }> = []; let totalSize = 0; for (const [directory, state] of directories) { for (const [path, entry] of state.diffCache) { const size = diffEntrySize(entry); entries.push({ directory, path, fetchedAt: entry.fetchedAt, size }); totalSize += size; } } if (entries.length <= DIFF_CACHE_MAX_GLOBAL_ENTRIES && totalSize <= DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) return directories; const next = new Map(directories); entries.sort((left, right) => left.fetchedAt - right.fetchedAt); let count = entries.length; for (const entry of entries) { if (count <= DIFF_CACHE_MAX_GLOBAL_ENTRIES && totalSize <= DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) break; const state = next.get(entry.directory); if (!state?.diffCache.has(entry.path)) continue; const diffCache = new Map(state.diffCache); diffCache.delete(entry.path); next.set(entry.directory, { ...state, diffCache }); count -= 1; totalSize -= entry.size; } return next; }; 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 => { const changed = new Set(); 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([...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([...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([...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); const initialGitRuntimeKey = activeGitRuntimeKey; export const useGitStore = create()( devtools( (set, get) => ({ runtimeKey: initialGitRuntimeKey, directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey), activeDirectory: null, resetForRuntimeSwitch: (runtimeKey) => { gitRuntimeGeneration += 1; activeGitRuntimeKey = runtimeKey; requestGenerationByChannel.clear(); statusMutationRevisionByDirectory.clear(); inFlightStatusFetches.clear(); inFlightEnsureAllByDirectory.clear(); inFlightDiffFetchesByDirectory.clear(); diffFetchGenerationByDirectory.clear(); set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), 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 runtimeKey = getRuntimeKey(); const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode); const existing = inFlightStatusFetches.get(statusFetchKey) ?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined); if (existing) { return existing; } const token = startRequest(directory, 'status', true); 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 (!isRequestCurrent(token, directory)) return false; } 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 (!isRequestCurrent(token, directory)) return false; const latestState = get().directories.get(directory) ?? createEmptyDirectoryState(); if (hasStatusChanged(latestState.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 && isRequestCurrent(token, directory)) { 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; } bumpStatusMutationRevision(get().runtimeKey, directory); 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; } bumpStatusMutationRevision(get().runtimeKey, directory); 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; } bumpStatusMutationRevision(get().runtimeKey, directory); const nextDirectories = new Map(directories); nextDirectories.set(directory, { ...dirState, indexRevision: dirState.indexRevision + 1, }); set({ directories: nextDirectories }); }, fetchBranches: async (directory, git) => { const token = startRequest(directory, 'branches'); { 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); if (!isRequestCurrent(token, directory)) return; 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(token.runtimeKey, directory, branches); } catch (error) { console.error('Failed to fetch git branches:', error); if (!isRequestCurrent(token, directory)) return; 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 token = startRequest(directory, 'log'); 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 }); if (!isRequestCurrent(token, directory)) return; 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); if (!isRequestCurrent(token, directory)) return; 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 token = startRequest(directory, 'identity'); { 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); if (!isRequestCurrent(token, directory)) return; 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); if (!isRequestCurrent(token, directory)) return; 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, expectedRuntimeKey) => { if (expectedRuntimeKey && expectedRuntimeKey !== get().runtimeKey) return; if (diffEntrySize(diff) > DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) return; 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: evictGlobalDiffCachesIfNeeded(newDirectories) }); }, clearDiffCache: (directory, filePaths) => { bumpDiffFetchGeneration(directory); startRequest(directory, 'diff'); const newDirectories = new Map(get().directories); const dirState = newDirectories.get(directory); if (!dirState || dirState.diffCache.size === 0) return; const nextDiffCache = new Map(dirState.diffCache); if (filePaths) { for (const filePath of filePaths) { nextDiffCache.delete(filePath); } } else { nextDiffCache.clear(); } if (nextDiffCache.size === dirState.diffCache.size) return; newDirectories.set(directory, { ...dirState, diffCache: nextDiffCache }); 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 token = startRequest(directory, 'diff'); 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(); 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((_, 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) || !isRequestCurrent(token, 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) || !isRequestCurrent(token, 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: evictGlobalDiffCachesIfNeeded(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 ensureKey = runtimeDirectoryKey(getRuntimeKey(), directory); const existing = inFlightEnsureAllByDirectory.get(ensureKey); 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[] = []; 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(ensureKey, promise); promise.finally(() => { if (inFlightEnsureAllByDirectory.get(ensureKey) === promise) { inFlightEnsureAllByDirectory.delete(ensureKey); } }); 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 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() }; const EMPTY_BRANCHES = new Map(); export const useGitAllBranches = (enabled = true) => { return useGitStore((state) => { if (!enabled) return EMPTY_BRANCHES; 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(); for (const [dir, dirState] of state.directories) { result.set(dir, dirState.status?.current ?? null); } allBranchesCacheRef.current = result; return result; }); }; export const useGitRepoStatusMap = (directories: string[]) => { const cacheRef = React.useRef>(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(); 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; }); };