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