refactor: centralize git and pr store refresh

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