fix(ui): reconcile git state after worktree changes
This commit is contained in:
@@ -198,6 +198,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
// about, such as the New Worktree dialog), so the probe never reads the
|
||||
// transient bootstrap files as the branch being dirty.
|
||||
const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory);
|
||||
const draftDirectoryNeedsFreshStatusRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -208,14 +209,24 @@ export function useDraftTarget(enabled: boolean) {
|
||||
|| newSessionDraft?.bootstrapPendingDirectory
|
||||
|| selectedDraftDirectoryBootstrapPending
|
||||
) {
|
||||
if (selectedDraftDirectoryBootstrapPending && selectedDraftDirectory) {
|
||||
draftDirectoryNeedsFreshStatusRef.current = selectedDraftDirectory;
|
||||
}
|
||||
setDirtyDraftDirectory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDirtyDraftDirectory(null);
|
||||
getGitStatus(selectedDraftDirectory, { mode: 'light' })
|
||||
const needsFreshStatus = draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory;
|
||||
const statusRequest = needsFreshStatus
|
||||
? getGitStatus(selectedDraftDirectory, { mode: 'light', fresh: true })
|
||||
: getGitStatus(selectedDraftDirectory, { mode: 'light' });
|
||||
statusRequest
|
||||
.then((status) => {
|
||||
if (!cancelled && needsFreshStatus && draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory) {
|
||||
draftDirectoryNeedsFreshStatusRef.current = null;
|
||||
}
|
||||
if (!cancelled && (status.files?.length ?? 0) > 0) {
|
||||
setDirtyDraftDirectory(selectedDraftDirectory);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -57,7 +56,6 @@ import {
|
||||
extractFirstChangedLineFromDiff,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
getMutatedToolPaths,
|
||||
getPatchText,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
@@ -108,14 +106,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const GIT_REFRESH_MUTATING_TOOLS = new Set([
|
||||
'bash',
|
||||
'edit',
|
||||
'write',
|
||||
'apply_patch',
|
||||
'patch',
|
||||
]);
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||
const duration = Math.max(0, (end ?? now) - start);
|
||||
const seconds = duration / 1000;
|
||||
@@ -1699,19 +1689,16 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
|
||||
const status = state?.status as string | undefined;
|
||||
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled';
|
||||
const isSuccessfullyFinalized = status === 'completed';
|
||||
const isError = status === 'error' || status === 'failed';
|
||||
|
||||
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
|
||||
const previousPartIdRef = React.useRef<string | undefined>(part.id);
|
||||
const observedActiveGitToolRef = React.useRef(!isFinalized);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousPartIdRef.current === part.id) {
|
||||
return;
|
||||
}
|
||||
previousPartIdRef.current = part.id;
|
||||
observedActiveGitToolRef.current = !isFinalized;
|
||||
// Reset latch only when tool identity changes.
|
||||
setActiveLatched(!isFinalized);
|
||||
}, [isFinalized, part.id]);
|
||||
@@ -1722,36 +1709,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}
|
||||
}, [isFinalized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFinalized) {
|
||||
observedActiveGitToolRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Historical completed tools can remount when the timeline changes.
|
||||
// Refresh only for a tool whose active state this instance observed.
|
||||
const finalizedAfterObservedActive = observedActiveGitToolRef.current;
|
||||
if (!finalizedAfterObservedActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
|
||||
observedActiveGitToolRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!currentDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
observedActiveGitToolRef.current = false;
|
||||
const paths = getMutatedToolPaths(normalizedPartTool, input, metadata)
|
||||
.map((path) => getRelativePath(path, currentDirectory));
|
||||
sessionEvents.requestGitRefresh({
|
||||
directory: currentDirectory,
|
||||
...(paths.length > 0 ? { paths } : {}),
|
||||
});
|
||||
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
|
||||
|
||||
const expandedContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getApplyPatchFilePath,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
getMutatedToolPaths,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
getRenderablePatchInfo,
|
||||
@@ -57,28 +56,6 @@ describe('toolDiffUtils', () => {
|
||||
})).toBe('/workspace/project/src/second.ts');
|
||||
});
|
||||
|
||||
test('lists every apply_patch mutation path, including both sides of a move', () => {
|
||||
expect(getMutatedToolPaths('apply_patch', undefined, {
|
||||
files: [
|
||||
{ filePath: '/workspace/project/src/deleted.ts', type: 'delete' },
|
||||
{
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/new.ts',
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
})).toEqual([
|
||||
'/workspace/project/src/deleted.ts',
|
||||
'/workspace/project/src/new.ts',
|
||||
'/workspace/project/src/old.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not invent paths for bash or task tools', () => {
|
||||
expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]);
|
||||
expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('selects the move patch and line from the same non-deleted file', () => {
|
||||
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
|
||||
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
|
||||
|
||||
@@ -200,29 +200,6 @@ export const getPrimaryToolPath = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getMutatedToolPaths = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string[] => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const paths = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!isRecord(file)) continue;
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (filePath) paths.add(filePath);
|
||||
if (file.type === 'move' && typeof file.filePath === 'string') {
|
||||
paths.add(file.filePath);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
const primaryPath = getPrimaryToolPath(toolName, input, metadata);
|
||||
return primaryPath ? [primaryPath] : [];
|
||||
};
|
||||
|
||||
const supportsDiffMetadata = (toolName: string): boolean => (
|
||||
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
|
||||
);
|
||||
|
||||
@@ -90,12 +90,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
}
|
||||
if (awaitingPostBootstrapStatus) {
|
||||
let cancelled = false;
|
||||
void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, { silent: true }))
|
||||
.finally(() => {
|
||||
void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, {
|
||||
force: true,
|
||||
silent: true,
|
||||
throwOnError: true,
|
||||
}))
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setPostBootstrapRefreshDirectory((current) => (current === gitDirectory ? null : current));
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -494,7 +494,7 @@ interface GitWorktreeAPI {
|
||||
|
||||
export interface GitAPI {
|
||||
checkIsGitRepository(directory: string): Promise<boolean>;
|
||||
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
|
||||
getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus>;
|
||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
|
||||
return gitHttp.checkIsGitRepository(directory);
|
||||
}
|
||||
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<import('./api/types').GitStatus> {
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<import('./api/types').GitStatus> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitStatus(directory, options);
|
||||
return gitHttp.getGitStatus(directory, options);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
unstageGitFiles,
|
||||
} from './gitApiHttp';
|
||||
import type { GitStatus } from './api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
type FetchCall = {
|
||||
input: RequestInfo | URL;
|
||||
@@ -141,6 +142,28 @@ describe('gitApiHttp index mutations', () => {
|
||||
});
|
||||
|
||||
describe('gitApiHttp status cache', () => {
|
||||
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
|
||||
installWindowMock();
|
||||
let statusRequestCount = 0;
|
||||
globalThis.fetch = async () => {
|
||||
statusRequestCount += 1;
|
||||
return jsonResponse(statusPayload({ behind: statusRequestCount }));
|
||||
};
|
||||
|
||||
try {
|
||||
const directory = '/repo-cache-tool-mutation';
|
||||
const first = await getGitStatus(directory);
|
||||
sessionEvents.requestGitRefresh({ directory });
|
||||
const afterMutation = await getGitStatus(directory);
|
||||
|
||||
expect(first.behind).toBe(1);
|
||||
expect(afterMutation.behind).toBe(2);
|
||||
expect(statusRequestCount).toBe(2);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalidates cached status after fetch', async () => {
|
||||
installWindowMock();
|
||||
const calls: FetchCall[] = [];
|
||||
@@ -188,6 +211,58 @@ describe('gitApiHttp status cache', () => {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh status bypasses an unexpired cached snapshot', async () => {
|
||||
installWindowMock();
|
||||
let statusRequestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
statusRequestCount += 1;
|
||||
return jsonResponse(statusPayload({ behind: statusRequestCount }));
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const directory = '/repo-cache-fresh';
|
||||
const first = await getGitStatus(directory);
|
||||
const cached = await getGitStatus(directory);
|
||||
const fresh = await getGitStatus(directory, { fresh: true });
|
||||
|
||||
expect(first.behind).toBe(1);
|
||||
expect(cached.behind).toBe(1);
|
||||
expect(fresh.behind).toBe(2);
|
||||
expect(statusRequestCount).toBe(2);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh status cannot be replaced in cache by an older in-flight response', async () => {
|
||||
installWindowMock();
|
||||
const statusResolvers: Array<(response: Response) => void> = [];
|
||||
// SAFETY: the mock accepts the same arguments as fetch and always returns
|
||||
// a pending Response promise controlled by this test.
|
||||
globalThis.fetch = (async () => new Promise<Response>((resolve) => {
|
||||
statusResolvers.push(resolve);
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const directory = '/repo-cache-fresh-race';
|
||||
const older = getGitStatus(directory);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const fresh = getGitStatus(directory, { fresh: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(statusResolvers).toHaveLength(2);
|
||||
statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' })));
|
||||
statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' })));
|
||||
|
||||
expect((await fresh).current).toBe('fresh');
|
||||
expect((await older).current).toBe('stale');
|
||||
expect((await getGitStatus(directory)).current).toBe('fresh');
|
||||
expect(statusResolvers).toHaveLength(2);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
|
||||
|
||||
@@ -40,7 +40,7 @@ import { normalizePath } from './pathNormalization';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { getRuntimeKey } from './runtime-switch';
|
||||
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
|
||||
import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation';
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||
@@ -60,8 +60,7 @@ const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'
|
||||
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
|
||||
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
|
||||
|
||||
const invalidateGitStatusCache = (directory: string): void => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const clearGitStatusCache = (runtimeKey: string, directory: string): void => {
|
||||
const key = getDirectoryCacheKey(runtimeKey, directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
|
||||
for (const mode of [undefined, 'light'] as const) {
|
||||
@@ -69,6 +68,13 @@ const invalidateGitStatusCache = (directory: string): void => {
|
||||
gitStatusCache.delete(statusKey);
|
||||
gitStatusInFlight.delete(statusKey);
|
||||
}
|
||||
};
|
||||
|
||||
subscribeGitStatusInvalidations((directory) => {
|
||||
clearGitStatusCache(getRuntimeKey(), directory);
|
||||
});
|
||||
|
||||
const invalidateGitStatusCache = (directory: string): void => {
|
||||
notifyGitStatusInvalidated(directory);
|
||||
};
|
||||
|
||||
@@ -162,9 +168,14 @@ export async function listGitDirectories(root: string): Promise<string[]> {
|
||||
.filter((path): path is string => path !== null);
|
||||
}
|
||||
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> {
|
||||
const mode = options?.mode;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
if (options?.fresh) {
|
||||
// A forced read must cross the transport cache boundary too. Advancing the
|
||||
// version also prevents an older in-flight response from repopulating it.
|
||||
clearGitStatusCache(runtimeKey, directory);
|
||||
}
|
||||
const key = getStatusCacheKey(runtimeKey, directory, mode);
|
||||
const now = Date.now();
|
||||
const cached = gitStatusCache.get(key);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Minimal notification channel for git status invalidation.
|
||||
*
|
||||
* Every successful status-affecting git mutation must call
|
||||
* Every confirmed status-affecting mutation must call
|
||||
* `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its
|
||||
* per-directory status mutation revision so an immediate refresh cannot join an
|
||||
* in-flight status request admitted before the mutation, and a stale response
|
||||
* cannot commit over newer authoritative state.
|
||||
* cannot commit over newer authoritative state. The HTTP adapter also subscribes
|
||||
* and clears its short-lived status cache.
|
||||
*
|
||||
* Runtime parity: this is about the store's in-flight status request, not about
|
||||
* adapter caching, so it applies to every runtime. The HTTP adapter in
|
||||
* `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the
|
||||
* VS Code bridge) have no cache of their own, so the dispatch layer in
|
||||
* `gitApi.ts` emits it for them after a successful runtime mutation. Either
|
||||
* path announces a mutation exactly once.
|
||||
* adapter caching, so it applies to every runtime. HTTP mutations emit from
|
||||
* `gitApiHttp.ts`; runtime adapters such as the VS Code bridge emit from the
|
||||
* dispatch layer in `gitApi.ts`. Tool and editor mutations emit through the
|
||||
* shared Git refresh hint. Each path announces a mutation exactly once.
|
||||
*/
|
||||
|
||||
type GitStatusInvalidationListener = (directory: string) => void;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export type SessionDeleteRequest = {
|
||||
@@ -24,6 +26,12 @@ const deleteListeners = new Set<DeleteListener>();
|
||||
const createListeners = new Set<CreateListener>();
|
||||
const directoryListeners = new Set<DirectoryListener>();
|
||||
const gitRefreshListeners = new Set<GitRefreshListener>();
|
||||
const gitMutatingTools = new Set(['bash', 'edit', 'write', 'apply_patch', 'patch']);
|
||||
|
||||
const normalizeToolName = (tool: string): string => {
|
||||
const parts = tool.trim().toLowerCase().split('.').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? '';
|
||||
};
|
||||
|
||||
export const sessionEvents = {
|
||||
onDeleteRequest(listener: DeleteListener) {
|
||||
@@ -67,6 +75,19 @@ export const sessionEvents = {
|
||||
if (!hint.directory.trim()) {
|
||||
return;
|
||||
}
|
||||
notifyGitStatusInvalidated(hint.directory);
|
||||
gitRefreshListeners.forEach((listener) => listener(hint));
|
||||
},
|
||||
requestGitRefreshForToolTransition(directory: string, previousPart: Part | undefined, nextPart: Part) {
|
||||
if (nextPart.type !== 'tool' || nextPart.state.status !== 'completed') {
|
||||
return;
|
||||
}
|
||||
if (previousPart?.type === 'tool' && previousPart.state.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
if (!gitMutatingTools.has(normalizeToolName(nextPart.tool))) {
|
||||
return;
|
||||
}
|
||||
sessionEvents.requestGitRefresh({ directory });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ const {
|
||||
getLatestWorktreeMetadata,
|
||||
listProjectWorktrees,
|
||||
partitionWorktreesByRegisteredProject,
|
||||
removeProjectWorktree,
|
||||
validateWorktreeCreate,
|
||||
worktreeMapsEqual,
|
||||
} = await import('./worktreeManager');
|
||||
@@ -365,6 +366,44 @@ describe('worktreeManager list invalidation', () => {
|
||||
expect(metadata.worktreeStatus).toBe('pending');
|
||||
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
|
||||
});
|
||||
|
||||
test('removes a worktree from sidebar topology owned by another registered checkout', async () => {
|
||||
const removed: WorktreeMetadata = {
|
||||
path: '/worktrees/removed',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'removed',
|
||||
label: 'removed',
|
||||
};
|
||||
const sibling: WorktreeMetadata = {
|
||||
path: '/worktrees/sibling',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'sibling',
|
||||
label: 'sibling',
|
||||
};
|
||||
const unrelatedEntries: WorktreeMetadata[] = [{
|
||||
path: '/other/worktree',
|
||||
projectDirectory: '/other',
|
||||
branch: 'other',
|
||||
label: 'other',
|
||||
}];
|
||||
sessionState.availableWorktreesByProject = new Map([
|
||||
['/worktrees/configured', [removed, sibling]],
|
||||
['/other', unrelatedEntries],
|
||||
]);
|
||||
sessionState.availableWorktrees = [removed, sibling, ...unrelatedEntries];
|
||||
sessionState.worktreeMetadata = new Map([
|
||||
['removed-session', removed],
|
||||
['sibling-session', sibling],
|
||||
]);
|
||||
|
||||
await removeProjectWorktree({ id: 'path:/repo', path: '/repo' }, removed);
|
||||
|
||||
expect(sessionState.availableWorktreesByProject.get('/worktrees/configured')).toEqual([sibling]);
|
||||
expect(sessionState.availableWorktreesByProject.get('/other')).toBe(unrelatedEntries);
|
||||
expect(sessionState.availableWorktrees).toEqual([sibling, ...unrelatedEntries]);
|
||||
expect(sessionState.worktreeMetadata.has('removed-session')).toBe(false);
|
||||
expect(sessionState.worktreeMetadata.get('sibling-session')).toBe(sibling);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktreeMapsEqual', () => {
|
||||
|
||||
@@ -595,14 +595,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
||||
|
||||
// Update sidebar store so removed worktree disappears immediately
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const sidebarProjectKey = projectDirectory;
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const updatedByProject = new Map(currentByProject);
|
||||
const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? [];
|
||||
updatedByProject.set(
|
||||
sidebarProjectKey,
|
||||
projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath),
|
||||
);
|
||||
for (const [projectKey, projectWorktrees] of currentByProject) {
|
||||
const remainingWorktrees = projectWorktrees.filter(
|
||||
(candidate) => normalizePath(candidate.path) !== normalizedWorktreePath,
|
||||
);
|
||||
if (remainingWorktrees.length !== projectWorktrees.length) {
|
||||
updatedByProject.set(projectKey, remainingWorktrees);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up worktreeMetadata for sessions in the removed worktree
|
||||
const currentMetadata = useSessionUIStore.getState().worktreeMetadata;
|
||||
|
||||
@@ -154,7 +154,8 @@ Important properties:
|
||||
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
|
||||
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
|
||||
- a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today)
|
||||
- `fetchAll({ force: true })` forces the status fetch as well as the log refresh
|
||||
- `fetchStatus({ force: true })` and `fetchAll({ force: true })` cross both the store and runtime transport caches; a forced reconciliation must reach the active runtime rather than reuse an unexpired browser status snapshot
|
||||
- status requests do not start while a managed worktree bootstrap is pending, and a response admitted before bootstrap began is discarded if it completes after the directory enters `pending`; the `--no-checkout` population window is not user working-tree state
|
||||
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
|
||||
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
|
||||
|
||||
@@ -326,9 +327,11 @@ Do not raise limits casually.
|
||||
Expected model:
|
||||
|
||||
- `GitView` / `DiffView` ensure current-directory Git state when visible
|
||||
- the Git view gates its status-derived content and actions while a managed worktree bootstrap is pending, then keeps the gate closed until one forced fresh status read succeeds; refresh failure exposes retry without revealing the cached bootstrap snapshot
|
||||
- explicit Git actions refresh status/branches/log as needed
|
||||
- every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry
|
||||
- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint
|
||||
- the sync event handler issues one Git refresh hint when a live file-mutating tool first reaches `completed`; this does not depend on `ToolPart` mounting, and duplicate terminal events do not replay the hint
|
||||
- every Git refresh hint invalidates the store request generation and the HTTP status cache before visible consumers request status, so they share one post-mutation read instead of accepting a cached or pre-mutation response
|
||||
- a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops
|
||||
- refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView
|
||||
- targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GitStatus } from '@/lib/api/types';
|
||||
import { useGitStore } from './useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
|
||||
import { clearWorktreeBootstrapState, markWorktreeBootstrapPending } from '@/lib/worktrees/worktreeBootstrap';
|
||||
|
||||
// The real transport has no server in tests and fails as a generic error.
|
||||
// Tests that exercise other failure modes swap this implementation; the
|
||||
@@ -86,6 +87,7 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
|
||||
|
||||
describe('useGitStore', () => {
|
||||
beforeEach(() => {
|
||||
clearWorktreeBootstrapState('/repo');
|
||||
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
|
||||
});
|
||||
|
||||
@@ -198,8 +200,10 @@ describe('useGitStore', () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const requests: Deferred<GitStatus>[] = [];
|
||||
let statusCalls = 0;
|
||||
const git = createGitApi(() => {
|
||||
const statusOptions: Array<{ mode?: 'light'; fresh?: boolean } | undefined> = [];
|
||||
const git = createGitApi((_directory, options) => {
|
||||
statusCalls += 1;
|
||||
statusOptions.push(options);
|
||||
const request = createDeferred<GitStatus>();
|
||||
requests.push(request);
|
||||
return request.promise;
|
||||
@@ -212,6 +216,7 @@ describe('useGitStore', () => {
|
||||
const all = useGitStore.getState().fetchAll('/repo', git, { force: true });
|
||||
await Promise.resolve();
|
||||
expect(statusCalls).toBe(2);
|
||||
expect(statusOptions).toEqual([undefined, { fresh: true }]);
|
||||
|
||||
requests[1].resolve({ ...createStatus(), current: 'feature' });
|
||||
requests[0].resolve(createStatus());
|
||||
@@ -220,6 +225,56 @@ describe('useGitStore', () => {
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
|
||||
});
|
||||
|
||||
test('does not request status while worktree bootstrap is pending', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
let statusCalls = 0;
|
||||
const git = createGitApi(async () => {
|
||||
statusCalls += 1;
|
||||
return createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]);
|
||||
});
|
||||
|
||||
markWorktreeBootstrapPending('/repo');
|
||||
const changed = await useGitStore.getState().fetchStatus('/repo', git, { force: true });
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(statusCalls).toBe(0);
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not publish a status response after bootstrap becomes pending', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const request = createDeferred<GitStatus>();
|
||||
const git = createGitApi(() => request.promise);
|
||||
|
||||
const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||
await Promise.resolve();
|
||||
markWorktreeBootstrapPending('/repo');
|
||||
request.resolve(createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]));
|
||||
|
||||
expect(await loading).toBe(false);
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
|
||||
});
|
||||
|
||||
test('can propagate a forced status failure to a reconciliation owner', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const git = createGitApi(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => undefined;
|
||||
|
||||
try {
|
||||
await expect(useGitStore.getState().fetchStatus('/repo', git, {
|
||||
force: true,
|
||||
silent: true,
|
||||
throwOnError: true,
|
||||
})).rejects.toThrow('offline');
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('does not let an older status fetch undo an optimistic mutation', async () => {
|
||||
const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]);
|
||||
setDirectoryStatus(initial);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
|
||||
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
|
||||
import { getWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap';
|
||||
|
||||
const LOG_STALE_THRESHOLD = 10000;
|
||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||
@@ -28,6 +29,7 @@ const DIFF_CACHE_MAX_ENTRIES = 30;
|
||||
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
|
||||
type GitStatusFetchMode = 'full' | 'light';
|
||||
type GitStatusRequestOptions = { mode?: 'light'; fresh?: boolean };
|
||||
|
||||
// Discovery outcome for a root that is not itself a git repository. The three
|
||||
// states are mutually exclusive: a repository list (possibly empty), a failed
|
||||
@@ -64,7 +66,7 @@ interface GitStore {
|
||||
setActiveDirectory: (directory: string | null) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
||||
|
||||
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>;
|
||||
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean; throwOnError?: boolean }) => Promise<boolean>;
|
||||
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
||||
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
||||
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
||||
@@ -115,7 +117,7 @@ interface GitFileDiffResponse {
|
||||
|
||||
interface GitAPI {
|
||||
checkIsGitRepository: (directory: string) => Promise<boolean>;
|
||||
getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise<GitStatus>;
|
||||
getGitStatus: (directory: string, options?: GitStatusRequestOptions) => Promise<GitStatus>;
|
||||
getGitBranches: (directory: string) => Promise<GitBranch>;
|
||||
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
|
||||
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
|
||||
@@ -692,6 +694,9 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchStatus: async (directory, git, options = {}) => {
|
||||
if (getWorktreeBootstrapState(directory)?.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
|
||||
@@ -757,8 +762,17 @@ export const useGitStore = create<GitStore>()(
|
||||
return false;
|
||||
}
|
||||
|
||||
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
|
||||
let statusOptions: GitStatusRequestOptions | undefined;
|
||||
if (options.mode || options.force) {
|
||||
statusOptions = {};
|
||||
if (options.mode) statusOptions.mode = options.mode;
|
||||
if (options.force) statusOptions.fresh = true;
|
||||
}
|
||||
const newStatus = await git.getGitStatus(directory, statusOptions);
|
||||
if (!isRequestCurrent(token, directory)) return false;
|
||||
// A request admitted before worktree creation must not publish a
|
||||
// transient --no-checkout/reset snapshot after bootstrap begins.
|
||||
if (getWorktreeBootstrapState(directory)?.status === 'pending') return false;
|
||||
|
||||
const latestState = get().directories.get(directory) ?? createEmptyDirectoryState();
|
||||
if (hasStatusChanged(latestState.status, newStatus)) {
|
||||
@@ -830,6 +844,9 @@ export const useGitStore = create<GitStore>()(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git status:', error);
|
||||
if (options.throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (!silent && isRequestCurrent(token, directory)) {
|
||||
const newDirectories = new Map(get().directories);
|
||||
|
||||
@@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({
|
||||
import { INITIAL_STATE, type State } from "../types"
|
||||
import { ChildStoreManager, type DirectoryStore } from "../child-store"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
const {
|
||||
createEventRoutingIndex,
|
||||
handleEvent,
|
||||
@@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => {
|
||||
unsubscribe()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("refreshes Git once when a live mutating tool completes between renders", () => {
|
||||
const childStores = new ChildStoreManager()
|
||||
childStores.ensureChild("/repo", { bootstrap: false })
|
||||
const routingIndex = createEventRoutingIndex()
|
||||
const refreshes: Array<{ directory: string; paths?: string[] }> = []
|
||||
const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint))
|
||||
// SAFETY: this fixture supplies the SDK event discriminator and the tool
|
||||
// part identity, tool name, and state fields consumed by the reducer.
|
||||
const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "prt_tool",
|
||||
messageID: "msg_assistant",
|
||||
sessionID: "ses_a",
|
||||
type: "tool",
|
||||
tool,
|
||||
state: { status, input: {}, metadata: {} },
|
||||
},
|
||||
},
|
||||
}) as Event
|
||||
|
||||
try {
|
||||
handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey())
|
||||
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
|
||||
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
|
||||
handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey())
|
||||
handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey())
|
||||
|
||||
expect(refreshes).toEqual([{ directory: "/repo" }])
|
||||
} finally {
|
||||
unsubscribe()
|
||||
childStores.disposeAll()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
import { formatMessage, useI18nStore } from "@/lib/i18n"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
@@ -1818,6 +1819,10 @@ export function handleEvent(
|
||||
// type will mutate. This preserves reference identity for untouched slices
|
||||
// so Zustand selectors skip re-renders for unrelated subscribers.
|
||||
const current = getDirectoryEventState(store, batch)
|
||||
const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined
|
||||
const previousPart = updatedPart && "messageID" in updatedPart
|
||||
? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id)
|
||||
: undefined
|
||||
const draft: State = { ...current }
|
||||
const clonedFields = batch?.clonedFields.get(store) ?? new Set<keyof State>()
|
||||
const newlyClonedFields: Array<keyof State> = []
|
||||
@@ -1894,6 +1899,10 @@ export function handleEvent(
|
||||
const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed
|
||||
const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization
|
||||
|
||||
if (reducerChanged && updatedPart) {
|
||||
sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart)
|
||||
}
|
||||
|
||||
if (reducerChanged) {
|
||||
countSyncPerformance("reducerChangedEvents")
|
||||
const eventSessionID = getSessionIdFromPayload(payload) ?? undefined
|
||||
|
||||
@@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
return sendBridgeMessage<boolean>('api:git/check', { directory });
|
||||
},
|
||||
|
||||
getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise<GitStatus> => {
|
||||
getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> => {
|
||||
return sendBridgeMessage<GitStatus>('api:git/status', { directory, mode: options?.mode });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user