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). - 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: 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. - 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). - Sessions: archiving and unarchiving now stays scoped to the current instance and workspace (thanks to @alexandrereyes).
- Chat: assistant messages no longer render active HTML. - 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). - 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 ensureGitStatus = useGitStore((state) => state.ensureStatus);
const fetchGitStatus = useGitStore((state) => state.fetchStatus); const fetchGitStatus = useGitStore((state) => state.fetchStatus);
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
const [showAbortStatus, setShowAbortStatus] = React.useState(false); const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
@@ -449,9 +450,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!currentDirectory || !runtimeGit) return; if (!currentDirectory || !runtimeGit) return;
return sessionEvents.onGitRefreshHint((hint) => { return sessionEvents.onGitRefreshHint((hint) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return; 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) => { const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
if (!currentSessionId) return; if (!currentSessionId) return;
@@ -58,6 +58,7 @@ import {
extractFirstChangedLineFromDiff, extractFirstChangedLineFromDiff,
getDiffPatchEntries, getDiffPatchEntries,
getFirstChangedLineFromMetadata, getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPatchText, getPatchText,
getPrimaryDiffFromMetadata, getPrimaryDiffFromMetadata,
getPrimaryToolPath, getPrimaryToolPath,
@@ -113,7 +114,6 @@ const GIT_REFRESH_MUTATING_TOOLS = new Set([
'write', 'write',
'apply_patch', 'apply_patch',
'patch', 'patch',
'task',
]); ]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
@@ -1820,6 +1820,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const state = part.state; const state = part.state;
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
const currentDirectory = useEffectiveDirectory() ?? ''; const currentDirectory = useEffectiveDirectory() ?? '';
@@ -1828,18 +1831,19 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const status = state?.status as string | undefined; const status = state?.status as string | undefined;
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; 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 isError = status === 'error' || status === 'failed';
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized); const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
const previousPartIdRef = React.useRef<string | undefined>(part.id); const previousPartIdRef = React.useRef<string | undefined>(part.id);
const lastGitRefreshSignatureRef = React.useRef<string>(''); const observedActiveGitToolRef = React.useRef(!isFinalized);
React.useEffect(() => { React.useEffect(() => {
if (previousPartIdRef.current === part.id) { if (previousPartIdRef.current === part.id) {
return; return;
} }
previousPartIdRef.current = part.id; previousPartIdRef.current = part.id;
lastGitRefreshSignatureRef.current = ''; observedActiveGitToolRef.current = !isFinalized;
// Reset latch only when tool identity changes. // Reset latch only when tool identity changes.
setActiveLatched(!isFinalized); setActiveLatched(!isFinalized);
}, [isFinalized, part.id]); }, [isFinalized, part.id]);
@@ -1851,20 +1855,34 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}, [isFinalized]); }, [isFinalized]);
React.useEffect(() => { React.useEffect(() => {
if (!isFinalized || isError || !currentDirectory) { if (!isFinalized) {
return; observedActiveGitToolRef.current = true;
}
if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
return; return;
} }
const signature = `${part.id}:${status ?? 'unknown'}`; // Historical completed tools can remount when the timeline changes.
if (lastGitRefreshSignatureRef.current === signature) { // Refresh only for a tool whose active state this instance observed.
const finalizedAfterObservedActive = observedActiveGitToolRef.current;
if (!finalizedAfterObservedActive) {
return; return;
} }
lastGitRefreshSignatureRef.current = signature;
sessionEvents.requestGitRefresh({ directory: currentDirectory }); if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
}, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]); 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; const shouldNotifyStructuralChange = isFinalized || isTaskTool;
@@ -1890,10 +1908,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
} }
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]); }, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const input = stateWithData.input;
const time = stateWithData.time; const time = stateWithData.time;
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({ const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({
@@ -4,6 +4,7 @@ import {
getApplyPatchFilePath, getApplyPatchFilePath,
getDiffPatchEntries, getDiffPatchEntries,
getFirstChangedLineFromMetadata, getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPrimaryDiffFromMetadata, getPrimaryDiffFromMetadata,
getPrimaryToolPath, getPrimaryToolPath,
getRenderablePatchInfo, getRenderablePatchInfo,
@@ -54,6 +55,28 @@ describe('toolDiffUtils', () => {
})).toBe('/workspace/project/src/second.ts'); })).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', () => { test('selects the move patch and line from the same non-deleted file', () => {
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted'; const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
const movedPatch = '@@ -42 +42 @@\n-before\n+after'; const movedPatch = '@@ -42 +42 @@\n-before\n+after';
@@ -200,6 +200,29 @@ export const getPrimaryToolPath = (
return null; 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 => ( const supportsDiffMetadata = (toolName: string): boolean => (
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch' 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 { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { toAbsoluteFilePath } from '@/lib/path-utils'; import { toAbsoluteFilePath } from '@/lib/path-utils';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store'; import type { I18nKey } from '@/lib/i18n/store';
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
@@ -973,6 +974,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus); const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchStatus = useGitStore((state) => state.fetchStatus);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const setDiff = useGitStore((state) => state.setDiff); const setDiff = useGitStore((state) => state.setDiff);
const [displayFile, setDisplayFile] = React.useState<string | null>(null); const [displayFile, setDisplayFile] = React.useState<string | null>(null);
const [displayFileStaged, setDisplayFileStaged] = React.useState(false); 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 [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
const [loadFullFiles, setLoadFullFiles] = React.useState(false); const [loadFullFiles, setLoadFullFiles] = React.useState(false);
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0); const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState<Map<string, number>>(() => new Map());
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false); const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false); const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope); const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
@@ -1018,6 +1021,20 @@ export const DiffView: React.FC<DiffViewProps> = ({
const shouldPinAfterAlignRef = React.useRef(false); const shouldPinAfterAlignRef = React.useRef(false);
const visibleSyncFrameRef = React.useRef<number | null>(null); const visibleSyncFrameRef = React.useRef<number | null>(null);
const stackedStateScopeRef = React.useRef<string | 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(() => { const cancelPendingScrollAlignment = React.useCallback(() => {
pendingScrollTargetRef.current = null; pendingScrollTargetRef.current = null;
@@ -1171,13 +1188,17 @@ export const DiffView: React.FC<DiffViewProps> = ({
const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN; const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN;
const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN; const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN;
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
const sectionPositions: Array<{ path: string; top: number }> = [];
for (const [path, node] of fileSectionRefs.current) { for (const [path, node] of fileSectionRefs.current) {
if (!node || !expandedFiles.has(path)) continue; if (!node) continue;
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
sectionPositions.push({ path, top: rect.top });
if (!expandedFiles.has(path)) continue;
if (rect.bottom < top || rect.top > bottom) continue; if (rect.bottom < top || rect.top > bottom) continue;
next[path] = true; next[path] = true;
} }
lastScrollAnchorRef.current = findDiffScrollAnchor(rootRect.top, sectionPositions);
setMountedStackedFiles((previous) => { setMountedStackedFiles((previous) => {
let changed = false; let changed = false;
@@ -1259,9 +1280,40 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) { if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) {
return; 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 // Handle pending diff file from external navigation
React.useEffect(() => { React.useEffect(() => {
@@ -1399,11 +1451,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
return false; return false;
} }
const rootRect = scrollRoot.getBoundingClientRect(); const scrollOffset = node.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top;
const nodeRect = node.getBoundingClientRect(); scrollRoot.scrollTo({ top: scrollRoot.scrollTop + scrollOffset, behavior: 'auto' });
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));
return true; return true;
}, []); }, []);
@@ -1415,14 +1464,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
const maxAttempts = 20; const maxAttempts = 20;
let cancelled = false; let cancelled = false;
const cancelPending = () => { const cancelPending = (clearPinnedTarget = true) => {
if (cancelled) { if (cancelled) {
return; return;
} }
cancelled = true; cancelled = true;
pendingScrollTargetRef.current = null; pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false; shouldPinAfterAlignRef.current = false;
setPinnedStackedTarget(null); if (clearPinnedTarget) {
setPinnedStackedTarget(null);
}
if (pendingScrollFrameRef.current !== null) { if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current); window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null; pendingScrollFrameRef.current = null;
@@ -1455,6 +1506,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) { if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
setPinnedStackedTarget(currentTarget); setPinnedStackedTarget(currentTarget);
cancelPending(false);
return;
} }
cancelPending(); cancelPending();
}; };
@@ -1599,7 +1652,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content> <div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
{changedFiles.map((file) => ( {changedFiles.map((file) => (
<MultiFileDiffEntry <MultiFileDiffEntry
key={file.path} key={`${file.path}:${fileDiffRefreshNonce.get(file.path) ?? 0}`}
directory={effectiveDirectory} directory={effectiveDirectory}
file={file} file={file}
layout={getLayoutForFile(file)} layout={getLayoutForFile(file)}
@@ -74,6 +74,7 @@ import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
type FileNode = { type FileNode = {
name: string; name: string;
@@ -1655,6 +1656,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return false; return false;
} }
setFileContent(draftContent); 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)) { if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
diagramXmlRef.current = draftContent; diagramXmlRef.current = draftContent;
diagramSavedXmlRef.current = draftContent; diagramSavedXmlRef.current = draftContent;
@@ -1674,7 +1681,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} finally { } finally {
setIsSaving(false); 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(() => { React.useEffect(() => {
if (!isDirty) { if (!isDirty) {
+7 -2
View File
@@ -280,6 +280,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
setLogMaxCount, setLogMaxCount,
fetchIdentity, fetchIdentity,
prefetchDiffs, prefetchDiffs,
clearDiffCache,
moveStatusPathsOptimistically, moveStatusPathsOptimistically,
restoreStatus, restoreStatus,
bumpIndexRevision, bumpIndexRevision,
@@ -293,6 +294,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
setLogMaxCount: state.setLogMaxCount, setLogMaxCount: state.setLogMaxCount,
fetchIdentity: state.fetchIdentity, fetchIdentity: state.fetchIdentity,
prefetchDiffs: state.prefetchDiffs, prefetchDiffs: state.prefetchDiffs,
clearDiffCache: state.clearDiffCache,
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically, moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
restoreStatus: state.restoreStatus, restoreStatus: state.restoreStatus,
bumpIndexRevision: state.bumpIndexRevision, bumpIndexRevision: state.bumpIndexRevision,
@@ -861,9 +863,12 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
return; 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( const refreshStatusAndBranches = React.useCallback(
async (showErrors = true) => { 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 DeleteListener = (request: SessionDeleteRequest) => void;
type CreateListener = (request: SessionCreateRequest) => void; type CreateListener = (request: SessionCreateRequest) => void;
type DirectoryListener = () => void; type DirectoryListener = () => void;
type GitRefreshHint = { directory: string }; type GitRefreshHint = { directory: string; paths?: string[] };
type GitRefreshListener = (hint: GitRefreshHint) => void; type GitRefreshListener = (hint: GitRefreshHint) => void;
const deleteListeners = new Set<DeleteListener>(); const deleteListeners = new Set<DeleteListener>();
+4 -1
View File
@@ -252,7 +252,10 @@ Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible - `GitView` / `DiffView` ensure current-directory Git state when visible
- explicit Git actions refresh status/branches/log as needed - 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 - no root-level background Git polling
### PR ### PR
@@ -161,6 +161,32 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDiff('/repo', 'stale.ts')).toBe(null); 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 () => { test('keeps the newest branch request when completions are reversed', async () => {
const requests = [createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>(), createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>()]; const requests = [createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>(), createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>()];
let index = 0; 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; 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; 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>; fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => 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) }); set({ directories: evictGlobalDiffCachesIfNeeded(newDirectories) });
}, },
clearDiffCache: (directory) => { clearDiffCache: (directory, filePaths) => {
bumpDiffFetchGeneration(directory); bumpDiffFetchGeneration(directory);
startRequest(directory, 'diff'); startRequest(directory, 'diff');
const newDirectories = new Map(get().directories); const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory); const dirState = newDirectories.get(directory);
if (dirState) { if (!dirState || dirState.diffCache.size === 0) return;
newDirectories.set(directory, { ...dirState, diffCache: new Map() });
set({ directories: newDirectories }); 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) => { fetchAllDiffs: async (directory, git) => {