refactor: centralize git and pr store refresh

This commit is contained in:
Bohdan Triapitsyn
2026-04-03 19:47:01 +03:00
parent 2c56cb021f
commit d982171689
16 changed files with 822 additions and 1157 deletions
@@ -17,6 +17,7 @@ import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
@@ -159,6 +160,14 @@ const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
'write',
'apply_patch',
'patch',
'task',
]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
@@ -1573,12 +1582,14 @@ const ToolPart: React.FC<ToolPartProps> = ({
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
const previousPartIdRef = React.useRef<string | undefined>(part.id);
const lastGitRefreshSignatureRef = React.useRef<string>('');
React.useEffect(() => {
if (previousPartIdRef.current === part.id) {
return;
}
previousPartIdRef.current = part.id;
lastGitRefreshSignatureRef.current = '';
// Reset latch only when tool identity changes.
setActiveLatched(!isFinalized);
}, [isFinalized, part.id]);
@@ -1589,6 +1600,22 @@ const ToolPart: React.FC<ToolPartProps> = ({
}
}, [isFinalized]);
React.useEffect(() => {
if (!isFinalized || isError || !currentDirectory) {
return;
}
if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
return;
}
const signature = `${part.id}:${status ?? 'unknown'}`;
if (lastGitRefreshSignatureRef.current === signature) {
return;
}
lastGitRefreshSignatureRef.current = signature;
sessionEvents.requestGitRefresh({ directory: currentDirectory });
}, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
+23 -122
View File
@@ -27,8 +27,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStore } from '@/stores/useGitStore';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useGitBranchLabel } from '@/stores/useGitStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
@@ -59,7 +58,7 @@ import {
} from '@/components/ui/collapsible';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import type { UsageWindow } from '@/types';
import type { GitHubAuthStatus, GitHubPullRequestStatus } from '@/lib/api/types';
import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
@@ -86,48 +85,6 @@ const isSameContextUsage = (
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
};
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | null => {
const pr = status?.pr;
if (!pr) {
return null;
}
if (pr.state === 'merged') {
return 'merged';
}
if (pr.state === 'closed') {
return 'closed';
}
if (pr.draft) {
return 'draft';
}
const checksFailed = status?.checks?.state === 'failure';
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
if (checksFailed || notMergeable) {
return 'blocked';
}
return 'open';
};
const getPrVisualPriority = (state: PrVisualState): number => {
switch (state) {
case 'open':
return 5;
case 'blocked':
return 4;
case 'draft':
return 3;
case 'merged':
return 2;
case 'closed':
return 1;
default:
return 0;
}
};
const formatCompactHeaderLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
@@ -650,9 +607,6 @@ export const Header: React.FC<HeaderProps> = ({
if (!currentSessionId) return null;
return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null;
});
const gitDirectories = useGitStore((state) => state.directories);
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
const worktreeDirectory = React.useMemo(() => {
return normalize(worktreePath || '');
}, [worktreePath]);
@@ -673,6 +627,26 @@ export const Header: React.FC<HeaderProps> = ({
return worktreeDirectory || sessionDirectory || draftDirectory;
}, [draftDirectory, sessionDirectory, worktreeDirectory]);
const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || '');
if (!candidateDirectory) {
return null;
}
for (const worktrees of state.availableWorktreesByProject.values()) {
const match = worktrees.find((worktree) => normalize(worktree.path) === candidateDirectory);
const branch = match?.branch?.trim();
if (branch) {
return branch;
}
}
return null;
});
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
const currentSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return activeProjectLabel ?? 'OpenChamber';
@@ -685,78 +659,6 @@ export const Header: React.FC<HeaderProps> = ({
return resolveSessionDiffStats(currentSession?.summary as Parameters<typeof resolveSessionDiffStats>[0]);
}, [currentSession?.summary]);
const currentBranchLabel = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (directory) {
const gitBranch = gitDirectories.get(directory)?.status?.current?.trim();
if (gitBranch) {
return gitBranch;
}
}
return currentSessionWorktreeBranch;
}, [currentSessionWorktreeBranch, gitDirectories, openDirectory]);
const currentSessionPr = React.useMemo(() => {
const directory = normalize(openDirectory || '');
const branch = currentBranchLabel?.trim();
if (!directory || !branch) {
return null;
}
let bestMatch: { visualState: PrVisualState; number: number; canMerge: boolean | null; checksState: string | null } | null = null;
const prEntries = Object.values(prStatusEntries);
for (const entry of prEntries) {
const entryDirectory = normalize(entry.params?.directory ?? entry.identity?.directory ?? '');
const entryBranch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim() ?? '';
if (!entryDirectory || !entryBranch || entryDirectory !== directory || entryBranch !== branch) {
continue;
}
const visualState = getPrVisualState(entry.status ?? null);
const number = entry.status?.pr?.number;
if (!visualState || !number) {
continue;
}
const next = {
visualState,
number,
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
checksState: entry.status?.checks?.state ?? null,
};
if (!bestMatch || getPrVisualPriority(next.visualState) > getPrVisualPriority(bestMatch.visualState)) {
bestMatch = next;
}
}
return bestMatch;
}, [currentBranchLabel, openDirectory, prStatusEntries]);
const currentSessionPrLabel = React.useMemo(() => {
if (!currentSessionPr) {
return null;
}
switch (currentSessionPr.visualState) {
case 'merged':
return `PR #${currentSessionPr.number} merged`;
case 'closed':
return `PR #${currentSessionPr.number} closed`;
case 'draft':
return `PR #${currentSessionPr.number} draft`;
case 'blocked':
return `PR #${currentSessionPr.number} blocked`;
case 'open':
if (currentSessionPr.canMerge === true || currentSessionPr.checksState === 'success') {
return `PR #${currentSessionPr.number} ready`;
}
return `PR #${currentSessionPr.number} open`;
default:
return null;
}
}, [currentSessionPr]);
const currentSessionChanges = React.useMemo(() => {
if (currentSessionDiffStats) {
return currentSessionDiffStats;
@@ -1802,7 +1704,7 @@ export const Header: React.FC<HeaderProps> = ({
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">
{currentSessionTitle}
</div>
{(activeProjectLabel || currentBranchLabel || currentSessionPrLabel || hasNonZeroSessionChanges) ? (
{(activeProjectLabel || currentBranchLabel || hasNonZeroSessionChanges) ? (
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
{currentBranchLabel ? (
@@ -1811,7 +1713,6 @@ export const Header: React.FC<HeaderProps> = ({
<span className="truncate">{currentBranchLabel}</span>
</span>
) : null}
{currentSessionPrLabel ? <span className="truncate">{currentSessionPrLabel}</span> : null}
{hasNonZeroSessionChanges ? (
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
<span className="text-status-success/80">+{currentSessionChanges.additions}</span>
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useGitStore, useGitBranches } from '@/stores/useGitStore';
import { useGitStore, useGitBranches, useGitLoadingBranches } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -52,7 +52,7 @@ export interface BranchSelectorState {
export function useBranchOptions(directory: string | null): BranchSelectorState {
const { git } = useRuntimeAPIs();
const branches = useGitBranches(directory);
const isLoading = useGitStore((state) => state.isLoadingBranches);
const isLoading = useGitLoadingBranches(directory);
const fetchBranches = useGitStore((state) => state.fetchBranches);
// Fetch branches if not cached
@@ -52,7 +52,7 @@ import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { opencodeClient } from '@/lib/opencode/client';
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
@@ -237,7 +237,7 @@ export function NewWorktreeDialog({
// Use cached branches from Git store (instant if already fetched)
const branches = useGitBranches(projectDirectory);
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
const isLoadingBranches = useGitLoadingBranches(projectDirectory);
const fetchBranches = useGitStore((state) => state.fetchBranches);
// Compute local and remote branch lists (same pattern as GitView)
@@ -15,9 +15,8 @@ import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import type { GitHubPullRequestStatus } from '@/lib/api/types';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore } from '@/stores/useGitStore';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { NewWorktreeDialog } from './NewWorktreeDialog';
@@ -37,7 +36,7 @@ import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
@@ -48,7 +47,6 @@ import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
@@ -71,6 +69,8 @@ import {
normalizePath,
} from './sidebar/utils';
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
@@ -105,46 +105,6 @@ type PrIndicator = {
} | null;
};
const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | null => {
const pr = status?.pr;
if (!pr) {
return null;
}
if (pr.state === 'merged') {
return 'merged';
}
if (pr.state === 'closed') {
return 'closed';
}
if (pr.draft) {
return 'draft';
}
const checksFailed = status?.checks?.state === 'failure';
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
if (checksFailed || notMergeable) {
return 'blocked';
}
return 'open';
};
const getPrVisualPriority = (state: PrVisualState): number => {
switch (state) {
case 'open':
return 5;
case 'blocked':
return 4;
case 'draft':
return 3;
case 'merged':
return 2;
case 'closed':
return 1;
default:
return 0;
}
};
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
@@ -304,7 +264,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionSearchContainerRef,
});
const gitDirectories = useGitStore((state) => state.directories);
const gitBranches = useGitAllBranches();
const sync = useSync();
const syncSessions = useSessions();
@@ -335,7 +295,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
const updateStore = useUpdateStore();
const sessions = React.useMemo(
@@ -365,7 +324,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const projectPath = normalizePath(project.path);
if (!projectPath) return;
try {
const isGitRepo = await checkIsGitRepository(projectPath);
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath));
if (!isGitRepo) return;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
@@ -487,7 +448,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
homeDirectory,
worktreeMetadata,
pinnedSessionIds,
gitDirectories,
gitBranches,
isVSCode,
});
@@ -891,11 +852,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}>;
}, [projects]);
const normalizedProjectPaths = React.useMemo(
() => normalizedProjects.map((project) => project.normalizedPath),
[normalizedProjects],
);
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths);
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
useProjectRepoStatus({
projects,
normalizedProjects,
normalizePath,
gitDirectories,
gitRepoStatus,
setProjectRepoStatus,
setProjectRootBranches,
});
@@ -1162,6 +1134,91 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}));
}, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]);
const prLookupKeys = React.useMemo(() => {
const keys = new Set<string>();
sectionsForSidebarRender.forEach((section) => {
section.groups.forEach((group) => {
const directory = normalizePath(group.directory ?? null);
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
if (!directory || !branch) {
return;
}
keys.add(getGitHubPrStatusKey(directory, branch));
});
});
return [...keys];
}, [gitBranches, sectionsForSidebarRender]);
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
React.useEffect(() => {
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) {
return;
}
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
sectionsForSidebarRender.forEach((section) => {
if (collapsedProjects.has(section.project.id)) {
return;
}
section.groups.forEach((group) => {
const directory = normalizePath(group.directory ?? null);
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
if (!directory || !branch) {
return;
}
const key = getGitHubPrStatusKey(directory, branch);
const entry = useGitHubPrStatusStore.getState().entries[key];
if (!entry || !entry.isInitialStatusResolved) {
missingTargets.push({ directory, branch });
}
});
});
if (missingTargets.length === 0) {
return;
}
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>();
missingTargets.forEach((target) => {
const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, target);
}
});
uniqueTargets.forEach((target, key) => {
ensurePrStatusEntry(key);
setPrStatusParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: target.remoteName ?? null,
canShow: true,
github,
githubAuthChecked,
githubConnected: githubAuthStatus.connected,
});
});
void refreshPrStatusTargets([...uniqueTargets.values()], {
force: true,
silent: true,
markInitialResolved: true,
});
}, [
collapsedProjects,
ensurePrStatusEntry,
github,
githubAuthChecked,
githubAuthStatus?.connected,
gitBranches,
refreshPrStatusTargets,
sectionsForSidebarRender,
setPrStatusParams,
]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const mobileHeaderActionButtonClass =
@@ -1280,55 +1337,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const prVisualStateByDirectoryBranch = React.useMemo(() => {
const result = new Map<string, PrIndicator>();
Object.values(prStatusEntries).forEach((entry) => {
const directory = normalizePath(entry.params?.directory ?? entry.identity?.directory ?? null);
const branch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim();
if (!directory || !branch) {
return;
}
const state = getPrVisualState(entry.status ?? null);
const pr = entry.status?.pr;
if (!state || !pr?.number) {
return;
}
const key = `${directory}::${branch}`;
const nextIndicator: PrIndicator = {
visualState: state,
number: pr.number,
url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null,
state: pr.state,
draft: Boolean(pr.draft),
title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null,
base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null,
head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null,
checks: entry.status?.checks
? {
state: entry.status.checks.state,
total: entry.status.checks.total,
success: entry.status.checks.success,
failure: entry.status.checks.failure,
pending: entry.status.checks.pending,
}
: null,
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null,
repo: entry.status?.repo
? {
owner: entry.status.repo.owner,
repo: entry.status.repo.repo,
}
: null,
};
const existing = result.get(key);
if (!existing || getPrVisualPriority(nextIndicator.visualState) > getPrVisualPriority(existing.visualState)) {
result.set(key, nextIndicator);
}
});
for (const [key, summary] of prVisualSummaryMap) {
result.set(key, {
visualState: summary.visualState as PrVisualState,
number: summary.number,
url: summary.url,
state: summary.prState as 'open' | 'closed' | 'merged',
draft: summary.draft,
title: summary.title,
base: summary.base,
head: summary.head,
checks: summary.checks as PrIndicator['checks'],
canMerge: summary.canMerge,
mergeableState: summary.mergeableState,
repo: summary.repo,
});
}
return result;
}, [prStatusEntries]);
}, [prVisualSummaryMap]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
@@ -1,79 +1,59 @@
import React from 'react';
import { checkIsGitRepository } from '@/lib/gitApi';
import { mapWithConcurrency } from '@/lib/concurrency';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { mapWithConcurrency } from '@/lib/concurrency';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
type Project = { id: string; path: string; normalizedPath: string };
type DirectoryState = { status?: { current?: string | null } | null };
type Args = {
projects: Array<{ id: string; path: string }>;
normalizedProjects: Project[];
normalizePath: (value?: string | null) => string | null;
gitDirectories: Map<string, DirectoryState>;
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
setProjectRootBranches: React.Dispatch<React.SetStateAction<Map<string, string>>>;
};
export const useProjectRepoStatus = (args: Args): void => {
const {
projects,
normalizedProjects,
normalizePath,
gitDirectories,
gitRepoStatus,
setProjectRepoStatus,
setProjectRootBranches,
} = args;
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
// Derive repo status from centralized Git store
React.useEffect(() => {
let cancelled = false;
const normalized = projects
.map((project) => ({ id: project.id, path: normalizePath(project.path) }))
.filter((project): project is { id: string; path: string } => Boolean(project.path));
setProjectRepoStatus(new Map());
if (normalized.length === 0) {
return () => {
cancelled = true;
};
if (!git || normalizedProjects.length === 0) {
setProjectRepoStatus(new Map());
return;
}
void mapWithConcurrency(normalized, 2, async (project) => {
try {
const result = await checkIsGitRepository(project.path);
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, result);
return next;
});
}
} catch {
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, null);
return next;
});
}
}
// Trigger ensureStatus for each project to populate store
normalizedProjects.forEach((project) => {
void ensureStatus(project.normalizedPath, git);
});
}, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
return () => {
cancelled = true;
};
}, [normalizePath, projects, setProjectRepoStatus]);
// Read isGitRepo from the store-populated state
React.useEffect(() => {
const next = new Map<string, boolean | null>();
normalizedProjects.forEach((project) => {
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
});
setProjectRepoStatus(next);
}, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
const projectGitBranchesKey = React.useMemo(() => {
return normalizedProjects
.map((project) => {
const dirState = gitDirectories.get(project.normalizedPath);
return `${project.id}:${dirState?.status?.current ?? ''}`;
const branch = gitRepoStatus.get(project.normalizedPath)?.branch ?? '';
return `${project.id}:${branch}`;
})
.join('|');
}, [normalizedProjects, gitDirectories]);
}, [normalizedProjects, gitRepoStatus]);
React.useEffect(() => {
let cancelled = false;
@@ -15,7 +15,7 @@ type Args = {
homeDirectory: string | null;
worktreeMetadata: Map<string, WorktreeMetadata>;
pinnedSessionIds: Set<string>;
gitDirectories: Map<string, { status?: { current?: string | null } | null }>;
gitBranches: Map<string, string | null>;
isVSCode: boolean;
};
@@ -196,7 +196,7 @@ export const useSessionGrouping = (args: Args) => {
sortedWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitDirectories.get(directory)?.status?.current?.trim() || null;
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
@@ -234,7 +234,7 @@ export const useSessionGrouping = (args: Args) => {
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories, args.isVSCode],
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitBranches, args.isVSCode],
);
return {
+26 -15
View File
@@ -3,7 +3,7 @@ import { RiArrowDownSLine, RiArrowRightSLine, RiEditLine, RiGitCommitLine, RiLoa
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount } from '@/stores/useGitStore';
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount, useGitLoadingStatus } from '@/stores/useGitStore';
import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types';
import {
@@ -27,6 +27,7 @@ import { PierreDiffViewer } from './PierreDiffViewer';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { sessionEvents } from '@/lib/sessionEvents';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
@@ -128,6 +129,9 @@ const toAbsolutePath = (directory: string, filePath: string): string => {
return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath;
};
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
const getFirstChangedModifiedLine = (original: string, modified: string): number => {
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
@@ -935,8 +939,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const setDiff = useGitStore((state) => state.setDiff);
@@ -1109,16 +1114,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
return getLayoutForFile(selectedFileEntry);
}, [getLayoutForFile, selectedFileEntry]);
// Fetch git status on mount
// Ensure git status on mount
React.useEffect(() => {
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
void ensureStatus(effectiveDirectory, git);
}
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
}, [effectiveDirectory, setActiveDirectory, ensureStatus, git]);
React.useEffect(() => {
if (!effectiveDirectory) {
return;
}
return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) {
return;
}
void fetchStatus(effectiveDirectory, git);
});
}, [effectiveDirectory, fetchStatus, git]);
// Handle pending diff file from external navigation
React.useEffect(() => {
@@ -1744,19 +1759,15 @@ export const useDiffFileCount = (): number => {
const effectiveDirectory = useEffectiveDirectory();
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fileCount = useGitFileCount(effectiveDirectory ?? null);
React.useEffect(() => {
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
void ensureStatus(effectiveDirectory, git);
}
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
}, [effectiveDirectory, setActiveDirectory, ensureStatus, git]);
return fileCount;
};
+21 -10
View File
@@ -13,6 +13,8 @@ import {
useGitLog,
useGitIdentity,
useIsGitRepo,
useGitLoadingStatus,
useGitLoadingLog,
} from '@/stores/useGitStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
@@ -62,6 +64,7 @@ import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { cn } from '@/lib/utils';
import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBootstrapStatus } from '@/lib/gitApi';
import { sessionEvents } from '@/lib/sessionEvents';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -274,10 +277,11 @@ export const GitView: React.FC = () => {
const branches = useGitBranches(currentDirectory ?? null);
const log = useGitLog(currentDirectory ?? null);
const currentIdentity = useGitIdentity(currentDirectory ?? null);
const isLoading = useGitStore((state) => state.isLoadingStatus);
const isLogLoading = useGitStore((state) => state.isLoadingLog);
const isLoading = useGitLoadingStatus(currentDirectory ?? null);
const isLogLoading = useGitLoadingLog(currentDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchAll = useGitStore((state) => state.fetchAll);
const ensureAll = useGitStore((state) => state.ensureAll);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const fetchLog = useGitStore((state) => state.fetchLog);
@@ -723,15 +727,22 @@ export const GitView: React.FC = () => {
React.useEffect(() => {
if (currentDirectory) {
setActiveDirectory(currentDirectory);
const dirState = useGitStore.getState().directories.get(currentDirectory);
if (!dirState?.status) {
void fetchAll(currentDirectory, git, { force: true });
} else {
void fetchStatus(currentDirectory, git, { silent: true });
}
void ensureAll(currentDirectory, git);
}
}, [currentDirectory, setActiveDirectory, fetchAll, fetchStatus, git]);
}, [currentDirectory, setActiveDirectory, ensureAll, git]);
React.useEffect(() => {
if (!currentDirectory) {
return;
}
return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
return;
}
void fetchStatus(currentDirectory, git);
});
}, [currentDirectory, fetchStatus, git]);
const refreshStatusAndBranches = React.useCallback(
async (showErrors = true) => {