fix(ui): reconcile git state after worktree changes

This commit is contained in:
Iuliia Ivashko
2026-09-04 16:41:39 +03:00
parent bde6fed8a2
commit 94c90a16b6
20 changed files with 438 additions and 165 deletions
+123 -46
View File
@@ -20,6 +20,7 @@ import {
useGitLoadingLog,
} from '@/stores/useGitStore';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
@@ -203,8 +204,14 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
const [worktreeBootstrapSnapshot, setWorktreeBootstrapSnapshot] = React.useState<{
directory: string;
status: 'pending' | 'ready' | 'failed' | null;
} | null>(null);
const [postBootstrapRefresh, setPostBootstrapRefresh] = React.useState<{
directory: string;
status: 'refreshing' | 'failed';
} | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
@@ -329,7 +336,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
});
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
@@ -438,11 +444,13 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) {
setWorktreeBootstrapStatus(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
setWorktreeBootstrapSnapshot(null);
return;
}
const bootstrapDirectory = normalizePath(currentDirectory) ?? currentDirectory;
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
let cancelled = false;
let timeoutId: number | null = null;
@@ -452,7 +460,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (cancelled) {
return;
}
setWorktreeBootstrapStatus(next.status);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: next.status });
if (next.status === 'pending') {
timeoutId = window.setTimeout(() => {
void poll();
@@ -460,7 +468,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
} catch {
if (!cancelled) {
setWorktreeBootstrapStatus(null);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
}
}
};
@@ -475,37 +483,84 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
};
}, [isActive, currentDirectory]);
React.useEffect(() => {
const previous = previousBootstrapStatusRef.current;
previousBootstrapStatusRef.current = worktreeBootstrapStatus;
if (!currentDirectory || !git) {
return;
}
if (previous === 'pending' && worktreeBootstrapStatus === 'ready') {
setIsWaitingForGitRefreshAfterBootstrap(true);
void fetchAll(currentDirectory, git).finally(() => {
window.setTimeout(() => {
setIsWaitingForGitRefreshAfterBootstrap(false);
}, 1200);
});
}
if (worktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
}
}, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]);
const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
const isDraftBootstrapPendingForCurrentDirectory = Boolean(
currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory)
);
const sharedWorktreeBootstrapPending = useWorktreeBootstrapPending(currentDirectory ?? null);
const normalizedCurrentBootstrapDirectory = normalizePath(currentDirectory);
const observedWorktreeBootstrapStatus = worktreeBootstrapSnapshot?.directory === normalizedCurrentBootstrapDirectory
? worktreeBootstrapSnapshot.status
: null;
const isPendingWorktreeSetup = Boolean(
currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory)
currentDirectory
&& (
sharedWorktreeBootstrapPending
|| observedWorktreeBootstrapStatus === 'pending'
|| (isDraftBootstrapPendingForCurrentDirectory && newSessionDraft?.pendingWorktreeRequestId)
)
);
const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap;
const isPostBootstrapRefreshForCurrentDirectory = Boolean(
normalizedCurrentBootstrapDirectory
&& postBootstrapRefresh?.directory === normalizedCurrentBootstrapDirectory
);
React.useEffect(() => {
if (!normalizedCurrentBootstrapDirectory) return;
if (observedWorktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
return;
}
if (isPendingWorktreeSetup) {
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory && current.status === 'refreshing'
? current
: { directory: normalizedCurrentBootstrapDirectory, status: 'refreshing' }
));
return;
}
if (
postBootstrapRefresh?.directory !== normalizedCurrentBootstrapDirectory
|| postBootstrapRefresh.status !== 'refreshing'
|| !gitDirectory
|| !git
) {
return;
}
let cancelled = false;
void fetchStatus(gitDirectory, git, {
force: true,
silent: true,
throwOnError: true,
}).then(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
}).catch(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory
? { ...current, status: 'failed' }
: current
));
});
return () => {
cancelled = true;
};
}, [fetchStatus, git, gitDirectory, isPendingWorktreeSetup, normalizedCurrentBootstrapDirectory, observedWorktreeBootstrapStatus, postBootstrapRefresh, setDraftBootstrapPendingDirectory]);
const shouldHideGitState = isPendingWorktreeSetup || isPostBootstrapRefreshForCurrentDirectory;
const postBootstrapRefreshFailed = isPostBootstrapRefreshForCurrentDirectory
&& postBootstrapRefresh?.status === 'failed';
const initialSnapshot = React.useMemo(() => {
if (!gitDirectory) return null;
@@ -2336,6 +2391,42 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
);
}
if (shouldHideGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
{!postBootstrapRefreshFailed ? (
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
) : null}
<p className="typography-ui-label font-semibold text-foreground">
{postBootstrapRefreshFailed
? t('gitView.toast.refreshRepositoryFailed')
: t('gitView.empty.worktreeSetupInProgress')}
</p>
{!postBootstrapRefreshFailed ? (
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => {
if (!normalizedCurrentBootstrapDirectory) return;
setPostBootstrapRefresh({
directory: normalizedCurrentBootstrapDirectory,
status: 'refreshing',
});
}}
>
{t('gitView.empty.retryDiscovery')}
</Button>
)}
</div>
);
}
if (isGitRepo === null || (isGitRepo === true && !status)) {
return (
<div className="flex h-full items-center justify-center">
@@ -2348,20 +2439,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
if (isGitRepo === false) {
if (shouldHideNotGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.empty.worktreeSetupInProgress')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
</div>
);
}
// Nested repository discovery states (discovering, failed, unsupported,
// none found, or settling on the auto-selected repository).
return (