diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 72bbdc08021..77610151f7e 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -21,6 +21,7 @@ import { diffEditorScrollbarOptions } from './diff-editor-scrollbar-options' import { LargeDiffFallback } from './LargeDiffFallback' import { getLargeDiffRenderLimit } from './large-diff-render-limit' import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle' +import { useDiffViewerFirstChangeAutoScroll } from './useDiffViewerFirstChangeAutoScroll' import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action' import type { DiffViewerProps } from './diff-viewer-props' import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options' @@ -156,68 +157,12 @@ export default function DiffViewer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [modifiedEditor, popover?.lineNumber]) - // Why: center the first diff from a dedicated effect (not handleMount) so it runs after the decorator's view zones, which would otherwise shift content downward. - const didAutoScrollFirstDiffRef = useRef(false) - const didAutoScrollModelKeyRef = useRef(modelKey) - useEffect(() => { - if (didAutoScrollModelKeyRef.current !== modelKey) { - didAutoScrollModelKeyRef.current = modelKey - // Why: reset the per-modelKey one-shot here before the first-diff guard runs for the new file. - didAutoScrollFirstDiffRef.current = false - } - const diffEditor = diffEditorRef.current - if (!diffEditor || !modifiedEditor) { - return - } - if (didAutoScrollFirstDiffRef.current) { - return - } - if (diffViewStateCache.get(modelKey)) { - return - } - if (pendingScrollForThisViewer) { - // Why: decorator owns this scroll, so set the one-shot flag; else we'd re-run and overwrite it when pendingScroll flips back to null. - didAutoScrollFirstDiffRef.current = true - return - } - let rafId: number | null = null - const run = (): void => { - if (didAutoScrollFirstDiffRef.current) { - return - } - const changes = diffEditor.getLineChanges() - if (!changes || changes.length === 0) { - return - } - const line = Math.max(1, changes[0].modifiedStartLineNumber) - // Defer one frame so view zones are laid out before measuring; cancel any earlier rAF to avoid a redundant scroll. - if (rafId !== null) { - cancelAnimationFrame(rafId) - } - rafId = requestAnimationFrame(() => { - rafId = null - if (didAutoScrollFirstDiffRef.current || !modifiedEditor.getModel()) { - return - } - const top = modifiedEditor.getTopForLineNumber(line, true) - const editorHeight = modifiedEditor.getLayoutInfo().height - modifiedEditor.setPosition({ lineNumber: line, column: 1 }) - modifiedEditor.setScrollTop(Math.max(0, top - editorHeight / 2)) - didAutoScrollFirstDiffRef.current = true - }) - } - // Run now if the diff is ready; otherwise onDidUpdateDiff fires once the computation lands. - if (diffEditor.getLineChanges()) { - run() - } - const sub = diffEditor.onDidUpdateDiff(() => run()) - return () => { - sub.dispose() - if (rafId !== null) { - cancelAnimationFrame(rafId) - } - } - }, [modifiedEditor, modelKey, pendingScrollForThisViewer]) + useDiffViewerFirstChangeAutoScroll({ + diffEditorRef, + modifiedEditor, + modelKey, + pendingScrollCommentId: pendingScrollForThisViewer + }) const handleEnterLargeDiffFallback = useCallback(() => { // Why: on fallback transition, drop stale Monaco refs so decorators/save handlers don't talk to disposed UI. diff --git a/src/renderer/src/components/editor/useDiffViewerFirstChangeAutoScroll.ts b/src/renderer/src/components/editor/useDiffViewerFirstChangeAutoScroll.ts new file mode 100644 index 00000000000..f69ea98e02b --- /dev/null +++ b/src/renderer/src/components/editor/useDiffViewerFirstChangeAutoScroll.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import type { editor } from 'monaco-editor' +import { diffViewStateCache } from '@/lib/scroll-cache' + +type DiffViewerFirstChangeAutoScrollInput = { + diffEditorRef: RefObject + modifiedEditor: editor.ICodeEditor | null + modelKey: string + pendingScrollCommentId: string | null +} + +/** + * Centers the viewport on a diff's first change, once per modelKey. + * + * Why: lives outside handleMount so it sequences after the comment decorator's + * view zones, which would otherwise shift the measured content downward. + */ +export function useDiffViewerFirstChangeAutoScroll({ + diffEditorRef, + modifiedEditor, + modelKey, + pendingScrollCommentId +}: DiffViewerFirstChangeAutoScrollInput): void { + const didAutoScrollFirstDiffRef = useRef(false) + const didAutoScrollModelKeyRef = useRef(modelKey) + useEffect(() => { + if (didAutoScrollModelKeyRef.current !== modelKey) { + didAutoScrollModelKeyRef.current = modelKey + // Why: reset the per-modelKey one-shot here before the first-diff guard runs for the new file. + didAutoScrollFirstDiffRef.current = false + } + const diffEditor = diffEditorRef.current + if (!diffEditor || !modifiedEditor) { + return + } + if (didAutoScrollFirstDiffRef.current) { + return + } + if (diffViewStateCache.get(modelKey)) { + return + } + if (pendingScrollCommentId) { + // Why: decorator owns this scroll, so set the one-shot flag; else we'd re-run and overwrite it when pendingScroll flips back to null. + didAutoScrollFirstDiffRef.current = true + return + } + let rafId: number | null = null + const run = (): void => { + if (didAutoScrollFirstDiffRef.current) { + return + } + const changes = diffEditor.getLineChanges() + if (!changes || changes.length === 0) { + return + } + const line = Math.max(1, changes[0].modifiedStartLineNumber) + // Defer one frame so view zones are laid out before measuring; cancel any earlier rAF to avoid a redundant scroll. + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + rafId = requestAnimationFrame(() => { + rafId = null + if (didAutoScrollFirstDiffRef.current || !modifiedEditor.getModel()) { + return + } + const top = modifiedEditor.getTopForLineNumber(line, true) + const editorHeight = modifiedEditor.getLayoutInfo().height + modifiedEditor.setPosition({ lineNumber: line, column: 1 }) + modifiedEditor.setScrollTop(Math.max(0, top - editorHeight / 2)) + didAutoScrollFirstDiffRef.current = true + }) + } + // Run now if the diff is ready; otherwise onDidUpdateDiff fires once the computation lands. + if (diffEditor.getLineChanges()) { + run() + } + const sub = diffEditor.onDidUpdateDiff(() => run()) + return () => { + sub.dispose() + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + } + }, [diffEditorRef, modifiedEditor, modelKey, pendingScrollCommentId]) +}