fix(ui): keep diff refreshes targeted
This commit is contained in:
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user