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
+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),
);