From d9821716893a5ac4493170c32e5fe6c0e801ada3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Apr 2026 19:47:01 +0300 Subject: [PATCH] refactor: centralize git and pr store refresh --- .../chat/message/parts/ToolPart.tsx | 27 + packages/ui/src/components/layout/Header.tsx | 145 +----- .../components/multirun/BranchSelector.tsx | 4 +- .../components/session/NewWorktreeDialog.tsx | 4 +- .../src/components/session/SessionSidebar.tsx | 224 ++++---- .../sidebar/hooks/useProjectRepoStatus.ts | 74 +-- .../sidebar/hooks/useSessionGrouping.ts | 6 +- packages/ui/src/components/views/DiffView.tsx | 41 +- packages/ui/src/components/views/GitView.tsx | 31 +- .../hooks/useGitHubPrBackgroundTracking.ts | 481 ------------------ packages/ui/src/hooks/useGitPolling.tsx | 10 - packages/ui/src/hooks/useGitPollingHook.ts | 151 ------ packages/ui/src/lib/sessionEvents.ts | 15 + packages/ui/src/stores/DOCUMENTATION.md | 235 +++++++++ .../ui/src/stores/useGitHubPrStatusStore.ts | 168 +++--- packages/ui/src/stores/useGitStore.ts | 363 +++++++------ 16 files changed, 822 insertions(+), 1157 deletions(-) delete mode 100644 packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts delete mode 100644 packages/ui/src/hooks/useGitPolling.tsx delete mode 100644 packages/ui/src/hooks/useGitPollingHook.ts create mode 100644 packages/ui/src/stores/DOCUMENTATION.md diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 5f894cc3..bcde0dd2 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -17,6 +17,7 @@ import { getSyncChildStores } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionActivity } from '@/hooks/useSessionActivity'; import { opencodeClient } from '@/lib/opencode/client'; +import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Text } from '@/components/ui/text'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; @@ -159,6 +160,14 @@ const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160; const TASK_TOOL_IDLE_FETCH_LIMIT = 80; const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3; const TASK_TOOL_SETTLE_GRACE_MS = 2500; +const GIT_REFRESH_MUTATING_TOOLS = new Set([ + 'bash', + 'edit', + 'write', + 'apply_patch', + 'patch', + 'task', +]); const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS); @@ -1573,12 +1582,14 @@ const ToolPart: React.FC = ({ const [activeLatched, setActiveLatched] = React.useState(!isFinalized); const previousPartIdRef = React.useRef(part.id); + const lastGitRefreshSignatureRef = React.useRef(''); React.useEffect(() => { if (previousPartIdRef.current === part.id) { return; } previousPartIdRef.current = part.id; + lastGitRefreshSignatureRef.current = ''; // Reset latch only when tool identity changes. setActiveLatched(!isFinalized); }, [isFinalized, part.id]); @@ -1589,6 +1600,22 @@ const ToolPart: React.FC = ({ } }, [isFinalized]); + React.useEffect(() => { + if (!isFinalized || isError || !currentDirectory) { + return; + } + if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { + return; + } + + const signature = `${part.id}:${status ?? 'unknown'}`; + if (lastGitRefreshSignatureRef.current === signature) { + return; + } + lastGitRefreshSignatureRef.current = signature; + sessionEvents.requestGitRefresh({ directory: currentDirectory }); + }, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]); + const shouldNotifyStructuralChange = isFinalized || isTaskTool; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4432cb49..0589eb95 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -27,8 +27,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; -import { useGitStore } from '@/stores/useGitStore'; -import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { useGitBranchLabel } from '@/stores/useGitStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; @@ -59,7 +58,7 @@ import { } from '@/components/ui/collapsible'; import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; import type { UsageWindow } from '@/types'; -import type { GitHubAuthStatus, GitHubPullRequestStatus } from '@/lib/api/types'; +import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; @@ -86,48 +85,6 @@ const isSameContextUsage = ( && (a.lastMessageId ?? '') === (b.lastMessageId ?? ''); }; -type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; - -const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | 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 mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : ''; - const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty'; - if (checksFailed || notMergeable) { - return 'blocked'; - } - return 'open'; -}; - -const getPrVisualPriority = (state: PrVisualState): 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 formatCompactHeaderLabel = (value: string): string => { const trimmed = value.trim(); if (!trimmed) { @@ -650,9 +607,6 @@ export const Header: React.FC = ({ if (!currentSessionId) return null; return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null; }); - const gitDirectories = useGitStore((state) => state.directories); - const prStatusEntries = useGitHubPrStatusStore((state) => state.entries); - const worktreeDirectory = React.useMemo(() => { return normalize(worktreePath || ''); }, [worktreePath]); @@ -673,6 +627,26 @@ export const Header: React.FC = ({ return worktreeDirectory || sessionDirectory || draftDirectory; }, [draftDirectory, sessionDirectory, worktreeDirectory]); + const catalogWorktreeBranch = useSessionUIStore((state) => { + const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || ''); + if (!candidateDirectory) { + return null; + } + + for (const worktrees of state.availableWorktreesByProject.values()) { + const match = worktrees.find((worktree) => normalize(worktree.path) === candidateDirectory); + const branch = match?.branch?.trim(); + if (branch) { + return branch; + } + } + + return null; + }); + + const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); + const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + const currentSessionTitle = React.useMemo(() => { if (!currentSessionId) { return activeProjectLabel ?? 'OpenChamber'; @@ -685,78 +659,6 @@ export const Header: React.FC = ({ return resolveSessionDiffStats(currentSession?.summary as Parameters[0]); }, [currentSession?.summary]); - const currentBranchLabel = React.useMemo(() => { - const directory = normalize(openDirectory || ''); - if (directory) { - const gitBranch = gitDirectories.get(directory)?.status?.current?.trim(); - if (gitBranch) { - return gitBranch; - } - } - return currentSessionWorktreeBranch; - }, [currentSessionWorktreeBranch, gitDirectories, openDirectory]); - - const currentSessionPr = React.useMemo(() => { - const directory = normalize(openDirectory || ''); - const branch = currentBranchLabel?.trim(); - if (!directory || !branch) { - return null; - } - - let bestMatch: { visualState: PrVisualState; number: number; canMerge: boolean | null; checksState: string | null } | null = null; - const prEntries = Object.values(prStatusEntries); - for (const entry of prEntries) { - const entryDirectory = normalize(entry.params?.directory ?? entry.identity?.directory ?? ''); - const entryBranch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim() ?? ''; - if (!entryDirectory || !entryBranch || entryDirectory !== directory || entryBranch !== branch) { - continue; - } - - const visualState = getPrVisualState(entry.status ?? null); - const number = entry.status?.pr?.number; - if (!visualState || !number) { - continue; - } - - const next = { - visualState, - number, - canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null, - checksState: entry.status?.checks?.state ?? null, - }; - - if (!bestMatch || getPrVisualPriority(next.visualState) > getPrVisualPriority(bestMatch.visualState)) { - bestMatch = next; - } - } - - return bestMatch; - }, [currentBranchLabel, openDirectory, prStatusEntries]); - - const currentSessionPrLabel = React.useMemo(() => { - if (!currentSessionPr) { - return null; - } - - switch (currentSessionPr.visualState) { - case 'merged': - return `PR #${currentSessionPr.number} merged`; - case 'closed': - return `PR #${currentSessionPr.number} closed`; - case 'draft': - return `PR #${currentSessionPr.number} draft`; - case 'blocked': - return `PR #${currentSessionPr.number} blocked`; - case 'open': - if (currentSessionPr.canMerge === true || currentSessionPr.checksState === 'success') { - return `PR #${currentSessionPr.number} ready`; - } - return `PR #${currentSessionPr.number} open`; - default: - return null; - } - }, [currentSessionPr]); - const currentSessionChanges = React.useMemo(() => { if (currentSessionDiffStats) { return currentSessionDiffStats; @@ -1802,7 +1704,7 @@ export const Header: React.FC = ({
{currentSessionTitle}
- {(activeProjectLabel || currentBranchLabel || currentSessionPrLabel || hasNonZeroSessionChanges) ? ( + {(activeProjectLabel || currentBranchLabel || hasNonZeroSessionChanges) ? (
{activeProjectLabel ? {activeProjectLabel} : null} {currentBranchLabel ? ( @@ -1811,7 +1713,6 @@ export const Header: React.FC = ({ {currentBranchLabel} ) : null} - {currentSessionPrLabel ? {currentSessionPrLabel} : null} {hasNonZeroSessionChanges ? ( +{currentSessionChanges.additions} diff --git a/packages/ui/src/components/multirun/BranchSelector.tsx b/packages/ui/src/components/multirun/BranchSelector.tsx index d1ca9517..f8caf8c5 100644 --- a/packages/ui/src/components/multirun/BranchSelector.tsx +++ b/packages/ui/src/components/multirun/BranchSelector.tsx @@ -9,7 +9,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useGitStore, useGitBranches } from '@/stores/useGitStore'; +import { useGitStore, useGitBranches, useGitLoadingBranches } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; @@ -52,7 +52,7 @@ export interface BranchSelectorState { export function useBranchOptions(directory: string | null): BranchSelectorState { const { git } = useRuntimeAPIs(); const branches = useGitBranches(directory); - const isLoading = useGitStore((state) => state.isLoadingBranches); + const isLoading = useGitLoadingBranches(directory); const fetchBranches = useGitStore((state) => state.fetchBranches); // Fetch branches if not cached diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index b8f3c5f0..ff98f68c 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -52,7 +52,7 @@ import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { opencodeClient } from '@/lib/opencode/client'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useGitBranches, useGitStore } from '@/stores/useGitStore'; +import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -237,7 +237,7 @@ export function NewWorktreeDialog({ // Use cached branches from Git store (instant if already fetched) const branches = useGitBranches(projectDirectory); - const isLoadingBranches = useGitStore((state) => state.isLoadingBranches); + const isLoadingBranches = useGitLoadingBranches(projectDirectory); const fetchBranches = useGitStore((state) => state.fetchBranches); // Compute local and remote branch lists (same pattern as GitView) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 474063c7..f7d1b2f1 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -15,9 +15,8 @@ import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; -import type { GitHubPullRequestStatus } from '@/lib/api/types'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -import { useGitStore } from '@/stores/useGitStore'; +import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -37,7 +36,7 @@ import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus'; import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; -import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { SessionGroupSection } from './sidebar/SessionGroupSection'; @@ -48,7 +47,6 @@ import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; -import { checkIsGitRepository } from '@/lib/gitApi'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; import { @@ -71,6 +69,8 @@ import { normalizePath, } from './sidebar/utils'; import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -105,46 +105,6 @@ type PrIndicator = { } | null; }; -const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | 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 mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : ''; - const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty'; - if (checksFailed || notMergeable) { - return 'blocked'; - } - return 'open'; -}; - -const getPrVisualPriority = (state: PrVisualState): 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; - } -}; - interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; @@ -304,7 +264,7 @@ export const SessionSidebar: React.FC = ({ sessionSearchContainerRef, }); - const gitDirectories = useGitStore((state) => state.directories); + const gitBranches = useGitAllBranches(); const sync = useSync(); const syncSessions = useSessions(); @@ -335,7 +295,6 @@ export const SessionSidebar: React.FC = ({ const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const prStatusEntries = useGitHubPrStatusStore((state) => state.entries); const updateStore = useUpdateStore(); const sessions = React.useMemo( @@ -365,7 +324,9 @@ export const SessionSidebar: React.FC = ({ const projectPath = normalizePath(project.path); if (!projectPath) return; try { - const isGitRepo = await checkIsGitRepository(projectPath); + // Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery + const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; + const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath)); if (!isGitRepo) return; const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); if (cancelled || worktrees.length === 0) return; @@ -487,7 +448,7 @@ export const SessionSidebar: React.FC = ({ homeDirectory, worktreeMetadata, pinnedSessionIds, - gitDirectories, + gitBranches, isVSCode, }); @@ -891,11 +852,22 @@ export const SessionSidebar: React.FC = ({ }>; }, [projects]); + const normalizedProjectPaths = React.useMemo( + () => normalizedProjects.map((project) => project.normalizedPath), + [normalizedProjects], + ); + + const { github } = useRuntimeAPIs(); + const githubAuthStatus = useGitHubAuthStore((state) => state.status); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths); + const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); + const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); + const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); + useProjectRepoStatus({ - projects, normalizedProjects, - normalizePath, - gitDirectories, + gitRepoStatus, setProjectRepoStatus, setProjectRootBranches, }); @@ -1162,6 +1134,91 @@ export const SessionSidebar: React.FC = ({ })); }, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]); + const prLookupKeys = React.useMemo(() => { + const keys = new Set(); + sectionsForSidebarRender.forEach((section) => { + section.groups.forEach((group) => { + const directory = normalizePath(group.directory ?? null); + const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim(); + if (!directory || !branch) { + return; + } + keys.add(getGitHubPrStatusKey(directory, branch)); + }); + }); + return [...keys]; + }, [gitBranches, sectionsForSidebarRender]); + + const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys); + + React.useEffect(() => { + if (!githubAuthChecked || !githubAuthStatus?.connected || !github) { + return; + } + + const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = []; + + sectionsForSidebarRender.forEach((section) => { + if (collapsedProjects.has(section.project.id)) { + return; + } + + section.groups.forEach((group) => { + const directory = normalizePath(group.directory ?? null); + const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim(); + if (!directory || !branch) { + return; + } + const key = getGitHubPrStatusKey(directory, branch); + const entry = useGitHubPrStatusStore.getState().entries[key]; + if (!entry || !entry.isInitialStatusResolved) { + missingTargets.push({ directory, branch }); + } + }); + }); + + if (missingTargets.length === 0) { + return; + } + + const uniqueTargets = new Map(); + missingTargets.forEach((target) => { + const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null); + if (!uniqueTargets.has(key)) { + uniqueTargets.set(key, target); + } + }); + + uniqueTargets.forEach((target, key) => { + ensurePrStatusEntry(key); + setPrStatusParams(key, { + directory: target.directory, + branch: target.branch, + remoteName: target.remoteName ?? null, + canShow: true, + github, + githubAuthChecked, + githubConnected: githubAuthStatus.connected, + }); + }); + + void refreshPrStatusTargets([...uniqueTargets.values()], { + force: true, + silent: true, + markInitialResolved: true, + }); + }, [ + collapsedProjects, + ensurePrStatusEntry, + github, + githubAuthChecked, + githubAuthStatus?.connected, + gitBranches, + refreshPrStatusTargets, + sectionsForSidebarRender, + setPrStatusParams, + ]); + const desktopHeaderActionButtonClass = 'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed'; const mobileHeaderActionButtonClass = @@ -1280,55 +1337,24 @@ export const SessionSidebar: React.FC = ({ const prVisualStateByDirectoryBranch = React.useMemo(() => { const result = new Map(); - - Object.values(prStatusEntries).forEach((entry) => { - const directory = normalizePath(entry.params?.directory ?? entry.identity?.directory ?? null); - const branch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim(); - if (!directory || !branch) { - return; - } - const state = getPrVisualState(entry.status ?? null); - const pr = entry.status?.pr; - if (!state || !pr?.number) { - return; - } - - const key = `${directory}::${branch}`; - const nextIndicator: PrIndicator = { - visualState: state, - number: pr.number, - url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null, - state: pr.state, - draft: Boolean(pr.draft), - title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : 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 existing = result.get(key); - if (!existing || getPrVisualPriority(nextIndicator.visualState) > getPrVisualPriority(existing.visualState)) { - result.set(key, nextIndicator); - } - }); - + for (const [key, summary] of prVisualSummaryMap) { + result.set(key, { + visualState: summary.visualState as PrVisualState, + number: summary.number, + url: summary.url, + state: summary.prState as 'open' | 'closed' | 'merged', + draft: summary.draft, + title: summary.title, + base: summary.base, + head: summary.head, + checks: summary.checks as PrIndicator['checks'], + canMerge: summary.canMerge, + mergeableState: summary.mergeableState, + repo: summary.repo, + }); + } return result; - }, [prStatusEntries]); + }, [prVisualSummaryMap]); const renderGroupSessions = React.useCallback( (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => ( diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts index c70836cc..4addc961 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -1,79 +1,59 @@ import React from 'react'; -import { checkIsGitRepository } from '@/lib/gitApi'; -import { mapWithConcurrency } from '@/lib/concurrency'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; +import { mapWithConcurrency } from '@/lib/concurrency'; +import { useGitStore } from '@/stores/useGitStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; type Project = { id: string; path: string; normalizedPath: string }; -type DirectoryState = { status?: { current?: string | null } | null }; - type Args = { - projects: Array<{ id: string; path: string }>; normalizedProjects: Project[]; - normalizePath: (value?: string | null) => string | null; - gitDirectories: Map; + gitRepoStatus: Map; setProjectRepoStatus: React.Dispatch>>; setProjectRootBranches: React.Dispatch>>; }; export const useProjectRepoStatus = (args: Args): void => { const { - projects, normalizedProjects, - normalizePath, - gitDirectories, + gitRepoStatus, setProjectRepoStatus, setProjectRootBranches, } = args; + const { git } = useRuntimeAPIs(); + const ensureStatus = useGitStore((state) => state.ensureStatus); + + // Derive repo status from centralized Git store React.useEffect(() => { - let cancelled = false; - const normalized = projects - .map((project) => ({ id: project.id, path: normalizePath(project.path) })) - .filter((project): project is { id: string; path: string } => Boolean(project.path)); - - setProjectRepoStatus(new Map()); - - if (normalized.length === 0) { - return () => { - cancelled = true; - }; + if (!git || normalizedProjects.length === 0) { + setProjectRepoStatus(new Map()); + return; } - void mapWithConcurrency(normalized, 2, async (project) => { - try { - const result = await checkIsGitRepository(project.path); - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, result); - return next; - }); - } - } catch { - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, null); - return next; - }); - } - } + // Trigger ensureStatus for each project to populate store + normalizedProjects.forEach((project) => { + void ensureStatus(project.normalizedPath, git); }); + }, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]); - return () => { - cancelled = true; - }; - }, [normalizePath, projects, setProjectRepoStatus]); + // Read isGitRepo from the store-populated state + React.useEffect(() => { + const next = new Map(); + normalizedProjects.forEach((project) => { + next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null); + }); + setProjectRepoStatus(next); + }, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]); const projectGitBranchesKey = React.useMemo(() => { return normalizedProjects .map((project) => { - const dirState = gitDirectories.get(project.normalizedPath); - return `${project.id}:${dirState?.status?.current ?? ''}`; + const branch = gitRepoStatus.get(project.normalizedPath)?.branch ?? ''; + return `${project.id}:${branch}`; }) .join('|'); - }, [normalizedProjects, gitDirectories]); + }, [normalizedProjects, gitRepoStatus]); React.useEffect(() => { let cancelled = false; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index 1d2f56c1..29bdb78a 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -15,7 +15,7 @@ type Args = { homeDirectory: string | null; worktreeMetadata: Map; pinnedSessionIds: Set; - gitDirectories: Map; + gitBranches: Map; isVSCode: boolean; }; @@ -196,7 +196,7 @@ export const useSessionGrouping = (args: Args) => { sortedWorktrees.forEach((meta) => { const directory = normalizePath(meta.path) ?? meta.path; - const currentBranch = args.gitDirectories.get(directory)?.status?.current?.trim() || null; + const currentBranch = args.gitBranches.get(directory)?.trim() || null; const metadataBranch = meta.branch?.trim() || null; const shouldSyncLabelWithBranch = Boolean( currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch), @@ -234,7 +234,7 @@ export const useSessionGrouping = (args: Args) => { return groups; }, - [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories, args.isVSCode], + [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitBranches, args.isVSCode], ); return { diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index f1a4ad8b..2f3125d0 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,7 +3,7 @@ import { RiArrowDownSLine, RiArrowRightSLine, RiEditLine, RiGitCommitLine, RiLoa import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount } from '@/stores/useGitStore'; +import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount, useGitLoadingStatus } from '@/stores/useGitStore'; import { cn } from '@/lib/utils'; import type { GitStatus } from '@/lib/api/types'; import { @@ -27,6 +27,7 @@ import { PierreDiffViewer } from './PierreDiffViewer'; import { useDeviceInfo } from '@/lib/device'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; +import { sessionEvents } from '@/lib/sessionEvents'; // Minimum width for side-by-side diff view (px) const SIDE_BY_SIDE_MIN_WIDTH = 1100; @@ -128,6 +129,9 @@ const toAbsolutePath = (directory: string, filePath: string): string => { return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath; }; +const normalizePath = (value?: string | null): string => + (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); + const getFirstChangedModifiedLine = (original: string, modified: string): number => { const originalLines = original.split('\n'); const modifiedLines = modified.split('\n'); @@ -935,8 +939,9 @@ export const DiffView: React.FC = ({ const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); const status = useGitStatus(effectiveDirectory ?? null); - const isLoadingStatus = useGitStore((state) => state.isLoadingStatus); + const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); const setDiff = useGitStore((state) => state.setDiff); @@ -1109,16 +1114,26 @@ export const DiffView: React.FC = ({ return getLayoutForFile(selectedFileEntry); }, [getLayoutForFile, selectedFileEntry]); - // Fetch git status on mount + // Ensure git status on mount React.useEffect(() => { if (effectiveDirectory) { setActiveDirectory(effectiveDirectory); - const dirState = useGitStore.getState().directories.get(effectiveDirectory); - if (!dirState?.status) { - fetchStatus(effectiveDirectory, git); - } + void ensureStatus(effectiveDirectory, git); } - }, [effectiveDirectory, setActiveDirectory, fetchStatus, git]); + }, [effectiveDirectory, setActiveDirectory, ensureStatus, git]); + + React.useEffect(() => { + if (!effectiveDirectory) { + return; + } + + return sessionEvents.onGitRefreshHint((hint) => { + if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) { + return; + } + void fetchStatus(effectiveDirectory, git); + }); + }, [effectiveDirectory, fetchStatus, git]); // Handle pending diff file from external navigation React.useEffect(() => { @@ -1744,19 +1759,15 @@ export const useDiffFileCount = (): number => { const effectiveDirectory = useEffectiveDirectory(); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); - const fetchStatus = useGitStore((state) => state.fetchStatus); + const ensureStatus = useGitStore((state) => state.ensureStatus); const fileCount = useGitFileCount(effectiveDirectory ?? null); React.useEffect(() => { if (effectiveDirectory) { setActiveDirectory(effectiveDirectory); - - const dirState = useGitStore.getState().directories.get(effectiveDirectory); - if (!dirState?.status) { - fetchStatus(effectiveDirectory, git); - } + void ensureStatus(effectiveDirectory, git); } - }, [effectiveDirectory, setActiveDirectory, fetchStatus, git]); + }, [effectiveDirectory, setActiveDirectory, ensureStatus, git]); return fileCount; }; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 8ef8a75f..7e318143 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -13,6 +13,8 @@ import { useGitLog, useGitIdentity, useIsGitRepo, + useGitLoadingStatus, + useGitLoadingLog, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -62,6 +64,7 @@ import type { GitRemote } from '@/lib/gitApi'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { cn } from '@/lib/utils'; import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBootstrapStatus } from '@/lib/gitApi'; +import { sessionEvents } from '@/lib/sessionEvents'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; @@ -274,10 +277,11 @@ export const GitView: React.FC = () => { const branches = useGitBranches(currentDirectory ?? null); const log = useGitLog(currentDirectory ?? null); const currentIdentity = useGitIdentity(currentDirectory ?? null); - const isLoading = useGitStore((state) => state.isLoadingStatus); - const isLogLoading = useGitStore((state) => state.isLoadingLog); + const isLoading = useGitLoadingStatus(currentDirectory ?? null); + const isLogLoading = useGitLoadingLog(currentDirectory ?? null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const fetchAll = useGitStore((state) => state.fetchAll); + const ensureAll = useGitStore((state) => state.ensureAll); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const fetchLog = useGitStore((state) => state.fetchLog); @@ -723,15 +727,22 @@ export const GitView: React.FC = () => { React.useEffect(() => { if (currentDirectory) { setActiveDirectory(currentDirectory); - - const dirState = useGitStore.getState().directories.get(currentDirectory); - if (!dirState?.status) { - void fetchAll(currentDirectory, git, { force: true }); - } else { - void fetchStatus(currentDirectory, git, { silent: true }); - } + void ensureAll(currentDirectory, git); } - }, [currentDirectory, setActiveDirectory, fetchAll, fetchStatus, git]); + }, [currentDirectory, setActiveDirectory, ensureAll, git]); + + React.useEffect(() => { + if (!currentDirectory) { + return; + } + + return sessionEvents.onGitRefreshHint((hint) => { + if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { + return; + } + void fetchStatus(currentDirectory, git); + }); + }, [currentDirectory, fetchStatus, git]); const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { diff --git a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts deleted file mode 100644 index 94056273..00000000 --- a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts +++ /dev/null @@ -1,481 +0,0 @@ -import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; -import type { RuntimeAPIs } from '@/lib/api/types'; -import { mapWithConcurrency } from '@/lib/concurrency'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; -import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessions } from '@/sync/sync-context'; - -const MAX_BACKGROUND_PR_DIRECTORIES = 20; -const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000; -const BACKGROUND_DIRECTORY_REFRESH_TTL_MS = 2 * 60_000; -const BRANCH_REFRESH_INTERVAL_MS = 15_000; -const MAX_STATUS_FETCH_PER_TICK = 3; -const MAX_STATUS_FETCH_ON_RESUME = 5; -const STATUS_FETCH_CONCURRENCY = 2; -const PR_EVENTUAL_CONSISTENCY_REFRESH_DELAY_MS = 5_000; -const RESUME_REFRESH_DEBOUNCE_MS = 700; -const RESUME_FORCE_COOLDOWN_MS = 8_000; - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const normalized = trimmed.replace(/\\/g, '/'); - if (normalized === '/') { - return '/'; - } - return normalized.replace(/\/+$/, ''); -}; - -type SessionLike = Session & { - directory?: string | null; - project?: { worktree?: string | null } | null; -}; - -type BranchCacheEntry = { - branch: string | null; - tracking: string | null; - ahead: number; - behind: number; - fetchedAt: number; -}; - -type PrTarget = { - directory: string; - branch: string; - remoteName?: string | null; -}; - -type BranchRefreshOptions = { - forceCurrent?: boolean; - maxFetchCount?: number; -}; - -const getBranchRefreshTtl = (directory: string, currentDirectory: string | null): number => { - return directory === currentDirectory ? ACTIVE_DIRECTORY_REFRESH_TTL_MS : BACKGROUND_DIRECTORY_REFRESH_TTL_MS; -}; - -const hasRepoSignalChanged = (previous: BranchCacheEntry | undefined, next: BranchCacheEntry): boolean => { - if (!previous) { - return Boolean(next.branch); - } - - return previous.branch !== next.branch - || previous.tracking !== next.tracking - || previous.ahead !== next.ahead - || previous.behind !== next.behind; -}; - -const prioritizeDirectoriesForFetch = ( - directories: string[], - cache: Map, - currentDirectory: string | null, -): string[] => { - return [...directories].sort((left, right) => { - const leftPriority = left === currentDirectory ? 0 : 1; - const rightPriority = right === currentDirectory ? 0 : 1; - if (leftPriority !== rightPriority) { - return leftPriority - rightPriority; - } - - const leftFetchedAt = cache.get(left)?.fetchedAt ?? 0; - const rightFetchedAt = cache.get(right)?.fetchedAt ?? 0; - if (leftFetchedAt !== rightFetchedAt) { - return leftFetchedAt - rightFetchedAt; - } - - return left.localeCompare(right); - }); -}; - -const toPrTargets = (cache: Map, directories: string[]): PrTarget[] => { - const result: PrTarget[] = []; - directories.forEach((directory) => { - const cached = cache.get(directory); - if (!cached?.branch) { - return; - } - result.push({ - directory, - branch: cached.branch, - remoteName: null, - }); - }); - return result; -}; - -export const useGitHubPrBackgroundTracking = ( - github: RuntimeAPIs['github'] | undefined, - git: RuntimeAPIs['git'], -): void => { - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const projects = useProjectsStore((state) => state.projects); - const sessions = useSessions(); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); - - const githubAuthStatus = useGitHubAuthStore((state) => state.status); - const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); - const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); - - const syncBackgroundTargets = useGitHubPrStatusStore((state) => state.syncBackgroundTargets); - const refreshPrTargets = useGitHubPrStatusStore((state) => state.refreshTargets); - - const [branchCache, setBranchCache] = React.useState>(new Map()); - const branchCacheRef = React.useRef>(new Map()); - const targetsRef = React.useRef([]); - const burstTimeoutsRef = React.useRef>(new Map()); - const refreshInFlightRef = React.useRef(false); - const pendingRefreshRef = React.useRef(null); - const resumeRefreshTimeoutRef = React.useRef(null); - const lastResumeRefreshAtRef = React.useRef(0); - - React.useEffect(() => { - branchCacheRef.current = branchCache; - }, [branchCache]); - - const scheduleBurstRefresh = React.useCallback((targetsToRefresh: PrTarget[]) => { - if (targetsToRefresh.length === 0) { - return; - } - - const dedupedTargets = new Map(); - targetsToRefresh.forEach((target) => { - const key = `${target.directory}::${target.branch}`; - dedupedTargets.set(key, target); - }); - - dedupedTargets.forEach((target, key) => { - const existing = burstTimeoutsRef.current.get(key); - if (typeof existing === 'number') { - window.clearTimeout(existing); - } - - const timeoutId = window.setTimeout(() => { - burstTimeoutsRef.current.delete(key); - void refreshPrTargets([target], { - force: true, - silent: true, - markInitialResolved: true, - }); - }, PR_EVENTUAL_CONSISTENCY_REFRESH_DELAY_MS); - - burstTimeoutsRef.current.set(key, timeoutId); - }); - }, [refreshPrTargets]); - - React.useEffect(() => { - if (!github || githubAuthChecked) { - return; - } - void refreshGitHubAuthStatus(github); - }, [github, githubAuthChecked, refreshGitHubAuthStatus]); - - const candidateDirectories = React.useMemo(() => { - const ordered = new Map(); - const add = (value?: string | null) => { - const normalized = normalizePath(value); - if (!normalized || ordered.has(normalized)) { - return; - } - ordered.set(normalized, normalized); - }; - - add(currentDirectory); - projects.forEach((project) => { - add(project.path); - }); - availableWorktreesByProject.forEach((worktrees) => { - worktrees.forEach((worktree) => { - add(worktree.path); - }); - }); - worktreeMetadata.forEach((metadata) => { - add(metadata.path); - }); - - [...sessions] - .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) - .forEach((rawSession) => { - const session = rawSession as SessionLike; - add(session.directory ?? null); - add(session.project?.worktree ?? null); - }); - - return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES); - }, [availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); - - React.useEffect(() => { - let cancelled = false; - - const refreshBranches = async (options?: BranchRefreshOptions): Promise => { - const forceCurrent = options?.forceCurrent === true; - const maxFetchCount = Math.max(1, options?.maxFetchCount ?? MAX_STATUS_FETCH_PER_TICK); - const now = Date.now(); - const dueDirectories = candidateDirectories.filter((directory) => { - const cached = branchCacheRef.current.get(directory); - if (!cached) { - return true; - } - - if (forceCurrent && currentDirectory && directory === currentDirectory) { - return true; - } - - return now - cached.fetchedAt > getBranchRefreshTtl(directory, currentDirectory); - }); - - const directoriesToFetch = prioritizeDirectoriesForFetch( - dueDirectories, - branchCacheRef.current, - currentDirectory, - ).slice(0, maxFetchCount); - - if (directoriesToFetch.length === 0) { - return toPrTargets(branchCacheRef.current, candidateDirectories); - } - - const results = await mapWithConcurrency( - directoriesToFetch, - STATUS_FETCH_CONCURRENCY, - async (directory) => { - try { - const status = await git.getGitStatus(directory, { mode: 'light' }); - const branch = typeof status.current === 'string' ? status.current.trim() : ''; - return { - directory, - branch: branch && branch !== 'HEAD' ? branch : null, - tracking: typeof status.tracking === 'string' ? status.tracking : null, - ahead: typeof status.ahead === 'number' ? status.ahead : 0, - behind: typeof status.behind === 'number' ? status.behind : 0, - }; - } catch { - return { directory, branch: null, tracking: null, ahead: 0, behind: 0 }; - } - }, - ); - - if (cancelled) { - return []; - } - - const nextCache = new Map(branchCacheRef.current); - const changedTargets: PrTarget[] = []; - - results.forEach(({ directory, branch, tracking, ahead, behind }) => { - const previous = nextCache.get(directory); - const nextEntry = { - branch, - tracking, - ahead, - behind, - fetchedAt: Date.now(), - }; - - nextCache.set(directory, nextEntry); - - if (branch && hasRepoSignalChanged(previous, nextEntry)) { - changedTargets.push({ - directory, - branch, - remoteName: null, - }); - } - }); - - setBranchCache((prev) => { - let changed = false; - if (prev.size !== nextCache.size) { - changed = true; - } else { - for (const [key, value] of nextCache.entries()) { - const previous = prev.get(key); - if (!previous - || previous.branch !== value.branch - || previous.tracking !== value.tracking - || previous.ahead !== value.ahead - || previous.behind !== value.behind) { - changed = true; - break; - } - } - } - - branchCacheRef.current = nextCache; - - if (!changed) { - return prev; - } - - return nextCache; - }); - - if (changedTargets.length > 0) { - void refreshPrTargets(changedTargets, { - force: true, - silent: true, - markInitialResolved: true, - }); - scheduleBurstRefresh(changedTargets); - } - - return toPrTargets(nextCache, candidateDirectories); - }; - - const runRefresh = async (options?: BranchRefreshOptions): Promise => { - if (refreshInFlightRef.current) { - const previousPending = pendingRefreshRef.current; - pendingRefreshRef.current = { - forceCurrent: Boolean(previousPending?.forceCurrent || options?.forceCurrent), - maxFetchCount: Math.max( - previousPending?.maxFetchCount ?? 1, - options?.maxFetchCount ?? 1, - ), - }; - return []; - } - - refreshInFlightRef.current = true; - try { - return await refreshBranches(options); - } finally { - refreshInFlightRef.current = false; - const pending = pendingRefreshRef.current; - pendingRefreshRef.current = null; - if (pending) { - void runRefresh(pending); - } - } - }; - - // Delay initial PR tracking to avoid startup CPU burst - const startupDelayId = window.setTimeout(() => { - if (cancelled) return; - void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }); - }, 5_000); - - const intervalId = window.setInterval(() => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return; - } - void runRefresh({ maxFetchCount: MAX_STATUS_FETCH_PER_TICK }); - }, BRANCH_REFRESH_INTERVAL_MS); - - const refreshOnResume = () => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return; - } - - const now = Date.now(); - if (now - lastResumeRefreshAtRef.current < RESUME_FORCE_COOLDOWN_MS) { - return; - } - - if (resumeRefreshTimeoutRef.current !== null) { - window.clearTimeout(resumeRefreshTimeoutRef.current); - } - - resumeRefreshTimeoutRef.current = window.setTimeout(() => { - resumeRefreshTimeoutRef.current = null; - lastResumeRefreshAtRef.current = Date.now(); - void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }).then((nextTargets) => { - const currentTargets = nextTargets.length > 0 ? nextTargets : targetsRef.current; - if (currentTargets.length === 0) { - return; - } - - const activeTargets = currentDirectory - ? currentTargets.filter((target) => target.directory === currentDirectory) - : []; - - if (activeTargets.length > 0) { - void refreshPrTargets(activeTargets, { - force: true, - silent: true, - markInitialResolved: true, - }); - } - - void refreshPrTargets(currentTargets, { - force: true, - onlyExistingPr: true, - silent: true, - markInitialResolved: true, - }); - }); - }, RESUME_REFRESH_DEBOUNCE_MS); - }; - - window.addEventListener('focus', refreshOnResume); - document.addEventListener('visibilitychange', refreshOnResume); - - return () => { - cancelled = true; - window.clearTimeout(startupDelayId); - window.clearInterval(intervalId); - window.removeEventListener('focus', refreshOnResume); - document.removeEventListener('visibilitychange', refreshOnResume); - if (resumeRefreshTimeoutRef.current !== null) { - window.clearTimeout(resumeRefreshTimeoutRef.current); - resumeRefreshTimeoutRef.current = null; - } - pendingRefreshRef.current = null; - refreshInFlightRef.current = false; - }; - }, [candidateDirectories, currentDirectory, git, refreshPrTargets, scheduleBurstRefresh]); - - React.useEffect(() => { - const validDirectories = new Set(candidateDirectories); - setBranchCache((prev) => { - let changed = false; - const next = new Map(); - prev.forEach((value, key) => { - if (!validDirectories.has(key)) { - changed = true; - return; - } - next.set(key, value); - }); - if (!changed) { - return prev; - } - branchCacheRef.current = next; - return next; - }); - }, [candidateDirectories]); - - const targets = React.useMemo(() => { - return toPrTargets(branchCache, candidateDirectories); - }, [branchCache, candidateDirectories]); - - React.useEffect(() => { - targetsRef.current = targets; - }, [targets]); - - React.useEffect(() => { - const burstTimeouts = burstTimeoutsRef.current; - return () => { - burstTimeouts.forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - burstTimeouts.clear(); - }; - }, []); - - React.useEffect(() => { - syncBackgroundTargets({ - targets, - github, - githubAuthChecked, - githubConnected: githubAuthStatus?.connected ?? null, - }); - }, [github, githubAuthChecked, githubAuthStatus?.connected, syncBackgroundTargets, targets]); -}; diff --git a/packages/ui/src/hooks/useGitPolling.tsx b/packages/ui/src/hooks/useGitPolling.tsx deleted file mode 100644 index 2596a6bb..00000000 --- a/packages/ui/src/hooks/useGitPolling.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import { useGitPolling } from '@/hooks/useGitPollingHook'; - -/** - * Component wrapper for useGitPolling - use this inside RuntimeAPIProvider - */ -export function GitPollingProvider({ children }: { children: React.ReactNode }) { - useGitPolling(); - return <>{children}; -} diff --git a/packages/ui/src/hooks/useGitPollingHook.ts b/packages/ui/src/hooks/useGitPollingHook.ts deleted file mode 100644 index 004e8df5..00000000 --- a/packages/ui/src/hooks/useGitPollingHook.ts +++ /dev/null @@ -1,151 +0,0 @@ -import React from 'react'; -import { useGitStore } from '@/stores/useGitStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessions, useSessionStatus } from '@/sync/sync-context'; - -/** - * Background git polling hook - monitors git status regardless of which tab is open. - * Must be used inside RuntimeAPIProvider. - */ -export function useGitPolling() { - const FORCE_DIFF_REFRESH_TOOLS = React.useMemo(() => new Set([ - 'edit', - 'multiedit', - 'apply_patch', - 'write', - 'file_write', - 'create', - ]), []); - - const { git } = useRuntimeAPIs(); - const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessions = useSessions(); - const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); - const currentStatus = useSessionStatus(currentSessionId ?? ''); - const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); - const startPolling = useGitStore((state) => state.startPolling); - const setPollingMode = useGitStore((state) => state.setPollingMode); - const stopPolling = useGitStore((state) => state.stopPolling); - const fetchAll = useGitStore((state) => state.fetchAll); - const fetchStatus = useGitStore((state) => state.fetchStatus); - const clearDiffCache = useGitStore((state) => state.clearDiffCache); - const immediateRefreshTimerRef = React.useRef | null>(null); - const lastImmediateRefreshAtRef = React.useRef(0); - - const effectiveDirectory = React.useMemo(() => { - const worktreeMetadata = currentSessionId - ? worktreeMap.get(currentSessionId) ?? undefined - : undefined; - - const currentSession = sessions.find((session) => session.id === currentSessionId); - const sessionDirectory = (currentSession as { directory?: string | null } | undefined)?.directory ?? null; - - return worktreeMetadata?.path ?? sessionDirectory ?? fallbackDirectory ?? null; - }, [currentSessionId, sessions, worktreeMap, fallbackDirectory]); - - const activeSessionStatus = React.useMemo<'idle' | 'busy' | 'retry'>(() => { - if (!currentSessionId) { - return 'idle'; - } - const activeStatus = currentStatus?.type; - if (activeStatus === 'busy' || activeStatus === 'retry') { - return activeStatus; - } - return 'idle'; - }, [currentSessionId, currentStatus]); - - const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal'; - - React.useEffect(() => { - setPollingMode(pollingMode); - }, [pollingMode, setPollingMode]); - - const queueImmediateStatusRefresh = React.useCallback(( - delayMs: number = 300, - options?: { directory?: string | null; forceDiffRefresh?: boolean } - ) => { - if (!git) { - return; - } - - const hintedDirectory = typeof options?.directory === 'string' && options.directory.trim().length > 0 && options.directory !== 'global' - ? options.directory.trim() - : null; - const targetDirectory = hintedDirectory ?? effectiveDirectory; - if (!targetDirectory) { - return; - } - - const shouldForceDiffRefresh = options?.forceDiffRefresh === true; - - const now = Date.now(); - if (now - lastImmediateRefreshAtRef.current < 800) { - return; - } - - if (immediateRefreshTimerRef.current) { - clearTimeout(immediateRefreshTimerRef.current); - } - - immediateRefreshTimerRef.current = setTimeout(() => { - immediateRefreshTimerRef.current = null; - lastImmediateRefreshAtRef.current = Date.now(); - void (async () => { - const statusChanged = await fetchStatus(targetDirectory, git, { silent: true, mode: 'light' }); - if (shouldForceDiffRefresh && !statusChanged) { - clearDiffCache(targetDirectory); - } - })(); - }, delayMs); - }, [clearDiffCache, effectiveDirectory, fetchStatus, git]); - - React.useEffect(() => { - if (!effectiveDirectory || !git) { - stopPolling(); - return; - } - - setActiveDirectory(effectiveDirectory); - - void fetchAll(effectiveDirectory, git, { silentIfCached: true }); - startPolling(git); - - return () => { - stopPolling(); - }; - }, [activeSessionStatus, effectiveDirectory, fetchAll, git, setActiveDirectory, startPolling, stopPolling]); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - const handleGitRefreshHint = (event: Event) => { - const customEvent = event as CustomEvent<{ directory?: string | null; toolName?: string | null }>; - const toolName = typeof customEvent.detail?.toolName === 'string' - ? customEvent.detail.toolName.toLowerCase() - : null; - queueImmediateStatusRefresh(200, { - directory: customEvent.detail?.directory ?? null, - forceDiffRefresh: Boolean(toolName && FORCE_DIFF_REFRESH_TOOLS.has(toolName)), - }); - }; - - window.addEventListener('openchamber:git-refresh-hint', handleGitRefreshHint as EventListener); - return () => { - window.removeEventListener('openchamber:git-refresh-hint', handleGitRefreshHint as EventListener); - }; - }, [FORCE_DIFF_REFRESH_TOOLS, queueImmediateStatusRefresh]); - - React.useEffect(() => { - return () => { - if (immediateRefreshTimerRef.current) { - clearTimeout(immediateRefreshTimerRef.current); - immediateRefreshTimerRef.current = null; - } - }; - }, []); -} diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index b09a659f..c57434fa 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -17,10 +17,13 @@ export type SessionCreateRequest = { type DeleteListener = (request: SessionDeleteRequest) => void; type CreateListener = (request: SessionCreateRequest) => void; type DirectoryListener = () => void; +type GitRefreshHint = { directory: string }; +type GitRefreshListener = (hint: GitRefreshHint) => void; const deleteListeners = new Set(); const createListeners = new Set(); const directoryListeners = new Set(); +const gitRefreshListeners = new Set(); export const sessionEvents = { onDeleteRequest(listener: DeleteListener) { @@ -54,4 +57,16 @@ export const sessionEvents = { requestDirectoryDialog() { directoryListeners.forEach((listener) => listener()); }, + onGitRefreshHint(listener: GitRefreshListener) { + gitRefreshListeners.add(listener); + return () => { + gitRefreshListeners.delete(listener); + }; + }, + requestGitRefresh(hint: GitRefreshHint) { + if (!hint.directory.trim()) { + return; + } + gitRefreshListeners.forEach((listener) => listener(hint)); + }, }; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md new file mode 100644 index 00000000..6b7acf48 --- /dev/null +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -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` 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. diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index e8ab20d4..b8128c54 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -85,20 +85,12 @@ type GitHubPrStatusStore = { refresh: (key: string, options?: RefreshOptions) => Promise; refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise; 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(); const bootstrapTimers = new Map(); const inFlightBySignature = new Set(); const lastRefreshBySignature = new Map(); -const backgroundWatchingKeys = new Set(); - const createEntry = (): PrStatusEntry => ({ status: null, isLoading: false, @@ -607,60 +599,6 @@ export const useGitHubPrStatusStore = create()( }); }, - 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(); - 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()( }, ), ); + +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(); +let prKeyedCacheResult: Map = new Map(); + +export const usePrVisualSummaryByKeys = (keys: string[]) => { + return useGitHubPrStatusStore((state) => { + // Derive summaries for requested keys only + const nextSigs = new Map(); + const nextSummaries = new Map(); + + 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; + }); +}; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 4544ffd0..8de19192 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -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; activeDirectory: string | null; - recentDirectories: string[]; - - isLoadingStatus: boolean; - isLoadingLog: boolean; - isLoadingBranches: boolean; - isLoadingIdentity: boolean; - - pollIntervalId: ReturnType | 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; fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean; silentIfCached?: boolean }) => Promise; + ensureStatus: (directory: string, git: GitAPI) => Promise; + ensureAll: (directory: string, git: GitAPI) => Promise; + 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; } @@ -98,6 +91,7 @@ interface GitAPI { const inFlightDiffFetchesByDirectory = new Map>(); const diffFetchGenerationByDirectory = new Map(); const inFlightStatusFetchesByDirectory = new Map>(); +const inFlightEnsureAllByDirectory = new Map>(); 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()( 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()( 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()( } 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()( } 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()( 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()( }, 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()( 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()( 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()( 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[] = []; + + 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() }; + +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(); + 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>(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(); + 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>(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; + }); +}; + +export const useGitLoadingIdentity = (directory: string | null) => { + return useGitStore((state) => { + if (!directory) return false; + return state.directories.get(directory)?.isLoadingIdentity ?? false; + }); +};