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) => {
|
||||
|
||||
Reference in New Issue
Block a user