From 17c2d5ec3642ab15e5c38ce06cb398e2d9178c4e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 2 Aug 2026 21:46:22 +0300 Subject: [PATCH] fix(ui): keep diff refreshes targeted --- CHANGELOG.md | 1 + packages/ui/src/components/chat/ChatInput.tsx | 8 +- .../chat/message/parts/ToolPart.tsx | 45 +++++++---- .../chat/message/parts/toolDiffUtils.test.ts | 23 ++++++ .../chat/message/parts/toolDiffUtils.ts | 23 ++++++ packages/ui/src/components/views/DiffView.tsx | 75 ++++++++++++++++--- .../ui/src/components/views/FilesView.tsx | 9 ++- packages/ui/src/components/views/GitView.tsx | 9 ++- .../components/views/diffScrollAnchor.test.ts | 25 +++++++ .../src/components/views/diffScrollAnchor.ts | 35 +++++++++ packages/ui/src/lib/sessionEvents.ts | 2 +- packages/ui/src/stores/DOCUMENTATION.md | 5 +- packages/ui/src/stores/useGitStore.test.ts | 26 +++++++ packages/ui/src/stores/useGitStore.ts | 20 +++-- 14 files changed, 268 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/views/diffScrollAnchor.test.ts create mode 100644 packages/ui/src/components/views/diffScrollAnchor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0524503..c6a95052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b039d64d..feda2c44 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -373,6 +373,7 @@ const ChatInputComponent: React.FC = ({ 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 = ({ 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; diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 0067b05a..d39fc0e8 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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 = ({ }) => { 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 = ({ 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(!isFinalized); const previousPartIdRef = React.useRef(part.id); - const lastGitRefreshSignatureRef = React.useRef(''); + 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 = ({ }, [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 = ({ } }, [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 }>(() => ({ diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index cc69096e..62054f08 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 5bbe2efb..0501ba98 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -200,6 +200,29 @@ export const getPrimaryToolPath = ( return null; }; +export const getMutatedToolPaths = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): string[] => { + if (toolName === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + const paths = new Set(); + 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' ); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 46facb8c..ee776d65 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 = ({ 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(null); const [displayFileStaged, setDisplayFileStaged] = React.useState(false); @@ -981,6 +983,7 @@ export const DiffView: React.FC = ({ const [mountedStackedFiles, setMountedStackedFiles] = React.useState>(() => new Set()); const [loadFullFiles, setLoadFullFiles] = React.useState(false); const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0); + const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState>(() => 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 = ({ const shouldPinAfterAlignRef = React.useRef(false); const visibleSyncFrameRef = React.useRef(null); const stackedStateScopeRef = React.useRef(null); + const lastScrollAnchorRef = React.useRef(null); + const pendingScrollAnchorRestoreRef = React.useRef(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 = ({ const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN; const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN; const next: Record = {}; + 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 = ({ 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 = ({ 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 = ({ 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 = ({ if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) { setPinnedStackedTarget(currentTarget); + cancelPending(false); + return; } cancelPending(); }; @@ -1599,7 +1652,7 @@ export const DiffView: React.FC = ({
{changedFiles.map((file) => ( = ({ 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 = ({ 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) { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 989f9066..06485c92 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -280,6 +280,7 @@ export const GitView: React.FC = ({ isActive }) => { setLogMaxCount, fetchIdentity, prefetchDiffs, + clearDiffCache, moveStatusPathsOptimistically, restoreStatus, bumpIndexRevision, @@ -293,6 +294,7 @@ export const GitView: React.FC = ({ 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 = ({ 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) => { diff --git a/packages/ui/src/components/views/diffScrollAnchor.test.ts b/packages/ui/src/components/views/diffScrollAnchor.test.ts new file mode 100644 index 00000000..c3a8b0c5 --- /dev/null +++ b/packages/ui/src/components/views/diffScrollAnchor.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/views/diffScrollAnchor.ts b/packages/ui/src/components/views/diffScrollAnchor.ts new file mode 100644 index 00000000..f7d50ee3 --- /dev/null +++ b/packages/ui/src/components/views/diffScrollAnchor.ts @@ -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), +); diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index c57434fa..331383b6 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -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(); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 430ceb63..bca5d525 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 01180db1..2512989a 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -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>>(), createDeferred>>()]; let index = 0; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index d066f1e8..aec942ba 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -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; prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise; @@ -974,15 +974,25 @@ export const useGitStore = create()( 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) => {