fix(ui): keep diff refreshes targeted

This commit is contained in:
Bohdan Triapitsyn
2026-08-02 21:46:22 +03:00
parent 75832876fa
commit 17c2d5ec36
14 changed files with 268 additions and 38 deletions
+1
View File
@@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- Sessions: launching OpenChamber from a directory other than your project (for example your home folder) no longer produces repeated "not a git repository" errors that could stop sessions and projects from loading (thanks to @makeittech).
- Sidebar: a worktree shared by more than one project no longer appears twice (thanks to @makeittech).
- Sidebar: session titles no longer clip at the ends of their rows.
- Git/Diff: opening a changed file now jumps its header directly to the top, and live updates refresh only files that actually changed while preserving the current review position. Saves from the built-in file editor update the diff too.
- Sessions: archiving and unarchiving now stays scoped to the current instance and workspace (thanks to @alexandrereyes).
- Chat: assistant messages no longer render active HTML.
- VSCode: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui).
@@ -373,6 +373,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
@@ -449,9 +450,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!currentDirectory || !runtimeGit) return;
return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return;
void fetchGitStatus(currentDirectory, runtimeGit);
if (hint.paths?.length) {
clearGitDiffCache(currentDirectory, hint.paths);
}
void fetchGitStatus(currentDirectory, runtimeGit, { silent: true });
});
}, [currentDirectory, runtimeGit, fetchGitStatus]);
}, [clearGitDiffCache, currentDirectory, runtimeGit, fetchGitStatus]);
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
if (!currentSessionId) return;
@@ -58,6 +58,7 @@ import {
extractFirstChangedLineFromDiff,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPatchText,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
@@ -113,7 +114,6 @@ const GIT_REFRESH_MUTATING_TOOLS = new Set([
'write',
'apply_patch',
'patch',
'task',
]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
@@ -1820,6 +1820,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}) => {
const { t } = useI18n();
const state = part.state;
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
const currentDirectory = useEffectiveDirectory() ?? '';
@@ -1828,18 +1831,19 @@ 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 lastGitRefreshSignatureRef = React.useRef<string>('');
const observedActiveGitToolRef = React.useRef(!isFinalized);
React.useEffect(() => {
if (previousPartIdRef.current === part.id) {
return;
}
previousPartIdRef.current = part.id;
lastGitRefreshSignatureRef.current = '';
observedActiveGitToolRef.current = !isFinalized;
// Reset latch only when tool identity changes.
setActiveLatched(!isFinalized);
}, [isFinalized, part.id]);
@@ -1851,20 +1855,34 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}, [isFinalized]);
React.useEffect(() => {
if (!isFinalized || isError || !currentDirectory) {
return;
}
if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
if (!isFinalized) {
observedActiveGitToolRef.current = true;
return;
}
const signature = `${part.id}:${status ?? 'unknown'}`;
if (lastGitRefreshSignatureRef.current === signature) {
// 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;
}
lastGitRefreshSignatureRef.current = signature;
sessionEvents.requestGitRefresh({ directory: currentDirectory });
}, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]);
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 shouldNotifyStructuralChange = isFinalized || isTaskTool;
@@ -1890,10 +1908,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const input = stateWithData.input;
const time = stateWithData.time;
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({
@@ -4,6 +4,7 @@ import {
getApplyPatchFilePath,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getRenderablePatchInfo,
@@ -54,6 +55,28 @@ 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,6 +200,29 @@ 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'
);
+64 -11
View File
@@ -34,6 +34,7 @@ import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { toAbsoluteFilePath } from '@/lib/path-utils';
import { sessionEvents } from '@/lib/sessionEvents';
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
@@ -973,6 +974,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const setDiff = useGitStore((state) => state.setDiff);
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
@@ -981,6 +983,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState<Map<string, number>>(() => new Map());
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
@@ -1018,6 +1021,20 @@ export const DiffView: React.FC<DiffViewProps> = ({
const shouldPinAfterAlignRef = React.useRef(false);
const visibleSyncFrameRef = React.useRef<number | null>(null);
const stackedStateScopeRef = React.useRef<string | null>(null);
const lastScrollAnchorRef = React.useRef<DiffScrollAnchor | null>(null);
const pendingScrollAnchorRestoreRef = React.useRef<DiffScrollAnchor | null>(null);
const captureScrollAnchor = React.useCallback((): DiffScrollAnchor | null => {
const scrollRoot = diffScrollRef.current;
if (!scrollRoot) return null;
const rootTop = scrollRoot.getBoundingClientRect().top;
const sections: Array<{ path: string; top: number }> = [];
for (const [path, node] of fileSectionRefs.current) {
if (node) sections.push({ path, top: node.getBoundingClientRect().top });
}
return findDiffScrollAnchor(rootTop, sections);
}, []);
const cancelPendingScrollAlignment = React.useCallback(() => {
pendingScrollTargetRef.current = null;
@@ -1171,13 +1188,17 @@ export const DiffView: React.FC<DiffViewProps> = ({
const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN;
const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN;
const next: Record<string, boolean> = {};
const sectionPositions: Array<{ path: string; top: number }> = [];
for (const [path, node] of fileSectionRefs.current) {
if (!node || !expandedFiles.has(path)) continue;
if (!node) continue;
const rect = node.getBoundingClientRect();
sectionPositions.push({ path, top: rect.top });
if (!expandedFiles.has(path)) continue;
if (rect.bottom < top || rect.top > bottom) continue;
next[path] = true;
}
lastScrollAnchorRef.current = findDiffScrollAnchor(rootRect.top, sectionPositions);
setMountedStackedFiles((previous) => {
let changed = false;
@@ -1259,9 +1280,40 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) {
return;
}
void fetchStatus(effectiveDirectory, git);
if (hint.paths?.length) {
pendingScrollAnchorRestoreRef.current = captureScrollAnchor() ?? lastScrollAnchorRef.current;
clearDiffCache(effectiveDirectory, hint.paths);
setFileDiffRefreshNonce((previous) => {
const next = new Map(previous);
for (const path of hint.paths ?? []) {
next.set(path, (next.get(path) ?? 0) + 1);
}
return next;
});
}
void fetchStatus(effectiveDirectory, git, { silent: true });
});
}, [effectiveDirectory, fetchStatus, git]);
}, [captureScrollAnchor, clearDiffCache, effectiveDirectory, fetchStatus, git]);
React.useLayoutEffect(() => {
const anchor = pendingScrollAnchorRestoreRef.current;
if (!anchor) return;
pendingScrollAnchorRestoreRef.current = null;
const scrollRoot = diffScrollRef.current;
const node = fileSectionRefs.current.get(anchor.path);
if (!scrollRoot || !node) return;
const rootTop = scrollRoot.getBoundingClientRect().top;
const currentTopOffset = node.getBoundingClientRect().top - rootTop;
scrollRoot.scrollTop = getRestoredDiffScrollTop(
scrollRoot.scrollTop,
anchor.topOffset,
currentTopOffset,
scrollRoot.scrollHeight - scrollRoot.clientHeight,
);
lastScrollAnchorRef.current = anchor;
}, [fileDiffRefreshNonce]);
// Handle pending diff file from external navigation
React.useEffect(() => {
@@ -1399,11 +1451,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
return false;
}
const rootRect = scrollRoot.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - rootRect.top;
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
scrollRoot.scrollTop = Math.min(maxTop, Math.max(0, scrollRoot.scrollTop + delta));
const scrollOffset = node.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top;
scrollRoot.scrollTo({ top: scrollRoot.scrollTop + scrollOffset, behavior: 'auto' });
return true;
}, []);
@@ -1415,14 +1464,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
const maxAttempts = 20;
let cancelled = false;
const cancelPending = () => {
const cancelPending = (clearPinnedTarget = true) => {
if (cancelled) {
return;
}
cancelled = true;
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
setPinnedStackedTarget(null);
if (clearPinnedTarget) {
setPinnedStackedTarget(null);
}
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
@@ -1455,6 +1506,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
setPinnedStackedTarget(currentTarget);
cancelPending(false);
return;
}
cancelPending();
};
@@ -1599,7 +1652,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
{changedFiles.map((file) => (
<MultiFileDiffEntry
key={file.path}
key={`${file.path}:${fileDiffRefreshNonce.get(file.path) ?? 0}`}
directory={effectiveDirectory}
file={file}
layout={getLayoutForFile(file)}
@@ -74,6 +74,7 @@ import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
type FileNode = {
name: string;
@@ -1655,6 +1656,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return false;
}
setFileContent(draftContent);
if (root && isPathWithinRoot(selectedFile.path, root)) {
const relativePath = getDisplayPath(root, selectedFile.path);
if (relativePath) {
sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] });
}
}
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
diagramXmlRef.current = draftContent;
diagramSavedXmlRef.current = draftContent;
@@ -1674,7 +1681,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} finally {
setIsSaving(false);
}
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]);
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]);
React.useEffect(() => {
if (!isDirty) {
+7 -2
View File
@@ -280,6 +280,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
setLogMaxCount,
fetchIdentity,
prefetchDiffs,
clearDiffCache,
moveStatusPathsOptimistically,
restoreStatus,
bumpIndexRevision,
@@ -293,6 +294,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
setLogMaxCount: state.setLogMaxCount,
fetchIdentity: state.fetchIdentity,
prefetchDiffs: state.prefetchDiffs,
clearDiffCache: state.clearDiffCache,
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
restoreStatus: state.restoreStatus,
bumpIndexRevision: state.bumpIndexRevision,
@@ -861,9 +863,12 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
return;
}
void fetchStatus(currentDirectory, git);
if (hint.paths?.length) {
clearDiffCache(currentDirectory, hint.paths);
}
void fetchStatus(currentDirectory, git, { silent: true });
});
}, [isActive, currentDirectory, fetchStatus, git]);
}, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]);
const refreshStatusAndBranches = React.useCallback(
async (showErrors = true) => {
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test';
import { findDiffScrollAnchor, getRestoredDiffScrollTop } from './diffScrollAnchor';
describe('diff scroll anchoring', () => {
test('uses the last file section that reached the top of the viewport', () => {
expect(findDiffScrollAnchor(100, [
{ path: 'third.ts', top: 240 },
{ path: 'second.ts', top: 80 },
{ path: 'first.ts', top: -200 },
])).toEqual({ path: 'second.ts', topOffset: -20 });
});
test('uses the first section when no header reached the viewport top yet', () => {
expect(findDiffScrollAnchor(100, [
{ path: 'first.ts', top: 140 },
{ path: 'second.ts', top: 300 },
])).toEqual({ path: 'first.ts', topOffset: 40 });
});
test('restores the prior section offset and clamps at the scroll boundary', () => {
expect(getRestoredDiffScrollTop(500, -20, 80, 1000)).toBe(600);
expect(getRestoredDiffScrollTop(950, -20, 80, 1000)).toBe(1000);
});
});
@@ -0,0 +1,35 @@
export type DiffScrollAnchor = {
path: string;
topOffset: number;
};
export const findDiffScrollAnchor = (
rootTop: number,
sections: Array<{ path: string; top: number }>,
): DiffScrollAnchor | null => {
if (sections.length === 0) return null;
let beforeTop: { path: string; top: number } | null = null;
let afterTop: { path: string; top: number } | null = null;
for (const section of sections) {
if (section.top <= rootTop) {
if (!beforeTop || section.top > beforeTop.top) beforeTop = section;
} else if (!afterTop || section.top < afterTop.top) {
afterTop = section;
}
}
const anchor = beforeTop ?? afterTop;
if (!anchor) return null;
return { path: anchor.path, topOffset: anchor.top - rootTop };
};
export const getRestoredDiffScrollTop = (
scrollTop: number,
previousTopOffset: number,
currentTopOffset: number,
maxScrollTop: number,
): number => Math.min(
Math.max(0, maxScrollTop),
Math.max(0, scrollTop + currentTopOffset - previousTopOffset),
);
+1 -1
View File
@@ -17,7 +17,7 @@ export type SessionCreateRequest = {
type DeleteListener = (request: SessionDeleteRequest) => void;
type CreateListener = (request: SessionCreateRequest) => void;
type DirectoryListener = () => void;
type GitRefreshHint = { directory: string };
type GitRefreshHint = { directory: string; paths?: string[] };
type GitRefreshListener = (hint: GitRefreshHint) => void;
const deleteListeners = new Set<DeleteListener>();
+4 -1
View File
@@ -252,7 +252,10 @@ Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible
- explicit Git actions refresh status/branches/log as needed
- successful file-mutating tools can issue a one-shot Git refresh hint
- 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
- 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
- no root-level background Git polling
### PR
@@ -161,6 +161,32 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDiff('/repo', 'stale.ts')).toBe(null);
});
test('clears cached file contents when a git refresh hint invalidates diffs', () => {
setDirectoryStatus(createStatus(
{ 'src/index.ts': { insertions: 1, deletions: 1 } },
[{ path: 'src/index.ts', index: ' ', working_dir: 'M' }],
));
useGitStore.getState().setDiff('/repo', 'src/index.ts', { original: 'old', modified: 'stale' });
useGitStore.getState().clearDiffCache('/repo');
expect(useGitStore.getState().getDiff('/repo', 'src/index.ts')).toBe(null);
});
test('invalidates only the requested cached file contents', () => {
setDirectoryStatus(createStatus(undefined, [
{ path: 'src/first.ts', index: ' ', working_dir: 'M' },
{ path: 'src/second.ts', index: ' ', working_dir: 'M' },
]));
useGitStore.getState().setDiff('/repo', 'src/first.ts', { original: 'a', modified: 'b' });
useGitStore.getState().setDiff('/repo', 'src/second.ts', { original: 'c', modified: 'd' });
useGitStore.getState().clearDiffCache('/repo', ['src/first.ts']);
expect(useGitStore.getState().getDiff('/repo', 'src/first.ts')).toBe(null);
expect(useGitStore.getState().getDiff('/repo', 'src/second.ts')?.modified).toBe('d');
});
test('keeps the newest branch request when completions are reversed', async () => {
const requests = [createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>(), createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>()];
let index = 0;
+15 -5
View File
@@ -71,7 +71,7 @@ interface GitStore {
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }, expectedRuntimeKey?: string) => void;
clearDiffCache: (directory: string) => void;
clearDiffCache: (directory: string, filePaths?: string[]) => void;
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise<void>;
@@ -974,15 +974,25 @@ export const useGitStore = create<GitStore>()(
set({ directories: evictGlobalDiffCachesIfNeeded(newDirectories) });
},
clearDiffCache: (directory) => {
clearDiffCache: (directory, filePaths) => {
bumpDiffFetchGeneration(directory);
startRequest(directory, 'diff');
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory);
if (dirState) {
newDirectories.set(directory, { ...dirState, diffCache: new Map() });
set({ directories: newDirectories });
if (!dirState || dirState.diffCache.size === 0) return;
const nextDiffCache = new Map(dirState.diffCache);
if (filePaths) {
for (const filePath of filePaths) {
nextDiffCache.delete(filePath);
}
} else {
nextDiffCache.clear();
}
if (nextDiffCache.size === dirState.diffCache.size) return;
newDirectories.set(directory, { ...dirState, diffCache: nextDiffCache });
set({ directories: newDirectories });
},
fetchAllDiffs: async (directory, git) => {