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).
|
- 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'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user