diff --git a/apps/docs/app/(diffs)/_home/AgentUi.tsx b/apps/docs/app/(diffs)/_home/AgentUi.tsx index 757ca1581..a58fc2022 100644 --- a/apps/docs/app/(diffs)/_home/AgentUi.tsx +++ b/apps/docs/app/(diffs)/_home/AgentUi.tsx @@ -2,7 +2,12 @@ import { DEFAULT_THEMES, type FileDiffMetadata } from '@pierre/diffs'; import type { EditorOptions } from '@pierre/diffs/edit'; -import { File, FileDiff, Virtualizer } from '@pierre/diffs/react'; +import { + File, + FileDiff, + useWorkerPool, + Virtualizer, +} from '@pierre/diffs/react'; import { IconArrow, IconChevronSm, @@ -677,6 +682,7 @@ export function AgentUi({ }: AgentUiProps) { const session = AUI_SESSIONS[0]; const router = useRouter(); + const workerPool = useWorkerPool(); // Expands the windowed card into the fullscreen route, morphing the shared // `.aui` element via the View Transition. @@ -970,16 +976,20 @@ export function AgentUi({ const recordEditedStatsRef = useRef(recordEditedStats); recordEditedStatsRef.current = recordEditedStats; - // One FileDiffMetadata per changed file, parsed on first visit and reused - // on every revisit. Edit sessions write edits back into the metadata (the - // library treats the host's metadata as the diff's content owner and - // self-heals session-shaped metadata on re-render), so reusing the object - // is what keeps a diff's edited content across file switches — the editor's - // persist-state API covers only selections and scroll for diffs. - // Placeholder File surfaces need no equivalent: with `persistState` on the - // shared editor, the per-cacheKey document cache restores their edited - // contents (and undo history) on re-attach. - const diffsRef = useRef>(new Map()); + // One external diff baseline per changed file, created up front for worker + // priming and reused on every visit so its cache identity remains stable. + // The shared editor's per-cacheKey document cache restores edited contents + // and undo history when a file is revisited. + const [diffs] = useState>(() => { + const diffs = new Map(); + for (const file of session.changedFiles) { + const diff = getFileDiff(file); + diffs.set(file.path, diff); + void workerPool?.primeDiffHighlightCache(diff); + } + return diffs; + }); + // Paths the user has edited. Only consulted to stop an edited file from // hydrating out of its prerendered (pristine) server HTML on revisit. const editedPathsRef = useRef>(new Set()); @@ -1113,6 +1123,7 @@ export function AgentUi({ : null, [liveSession, activePath] ); + const fileDiff = activeFile != null ? diffs.get(activeFile.path) : undefined; // When the active path isn't a changed/added file (e.g. browsing the root // README or another explorer file), open editable placeholder contents @@ -1125,21 +1136,6 @@ export function AgentUi({ [activePath, activeFile] ); - // The active file's diff metadata: parsed once on first visit, then reused - // from the cache so revisits render the content the last edit session wrote - // back into it. - const fileDiff = useMemo(() => { - if (activeFile == null) { - return null; - } - let diff = diffsRef.current.get(activeFile.path); - if (diff == null) { - diff = getFileDiff(activeFile); - diffsRef.current.set(activeFile.path, diff); - } - return diff; - }, [activeFile]); - // Server-rendered, already-highlighted HTML for the active diff. Only safe // when the file is unedited so the markup matches `fileDiff`. const activePrerenderedHTML = diff --git a/apps/docs/app/(diffs)/_home/mockData.ts b/apps/docs/app/(diffs)/_home/mockData.ts index a48b92ded..50b0f1a71 100644 --- a/apps/docs/app/(diffs)/_home/mockData.ts +++ b/apps/docs/app/(diffs)/_home/mockData.ts @@ -175,7 +175,15 @@ export function getFileDiff( nextAfter?: string ): FileDiffMetadata { return parseDiffFromFile( - { name: file.path, contents: file.before }, - { name: file.path, contents: nextAfter ?? file.after } + { + name: file.path, + contents: file.before, + cacheKey: `${file.path}:before`, + }, + { + name: file.path, + contents: nextAfter ?? file.after, + cacheKey: `${file.path}:after`, + } ); } diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx index b1aaca774..40900f9e3 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx @@ -5,12 +5,16 @@ import { type DiffIndicators, type DiffLineAnnotation, type FileDiffOptions, + type FileOptions, isDiffAnnotationCollection, + isFileAnnotationCollection, + type LineAnnotation, type SelectedLineRange, } from '@pierre/diffs'; import type { Editor, EditorOptions } from '@pierre/diffs/edit'; import { type CodeViewReactOptions, + File, FileDiff, useStableCallback, useWorkerPool, @@ -50,6 +54,7 @@ import type { PlaygroundAnnotationMetadata } from './constants'; import { CODE_VIEW_ITEMS, ITEM_UNSAFE_CSS, + PLAYGROUND_FILE, PLAYGROUND_MARKERS, VIRTUALIZER_FILE_DIFFS, } from './constants'; @@ -106,7 +111,8 @@ const LINE_HOVER_HIGHLIGHT_OPTIONS = [ ] as const; const VIEW_MODE_OPTIONS = [ - { value: 'normal', label: 'Normal' }, + { value: 'diff', label: 'Diff' }, + { value: 'file', label: 'File' }, { value: 'virtualizer', label: 'Virtualizer (win)' }, { value: 'virtualizer-element', label: 'Virtualizer (el)' }, { value: 'codeview', label: 'CodeView' }, @@ -114,6 +120,12 @@ const VIEW_MODE_OPTIONS = [ const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = []; +const EMPTY_FILE_ANNOTATIONS: LineAnnotation[] = + []; + +function isDirectView(viewMode: ViewMode): boolean { + return viewMode === 'diff' || viewMode === 'file'; +} // Pure rendering options shared by all three view modes. These keys don't depend // on the annotation metadata generic, so a single annotation-agnostic type keeps @@ -301,11 +313,10 @@ function PlaygroundControlsContent({ {/* - The single global Edit toggle only makes sense for the one-file - Normal view. Virtualizer/CodeView show a per-file edit control in - each header instead. + The direct File and FileDiff views share one global Edit toggle. + Virtualizer/CodeView show a per-file edit control in each header. */} - {viewMode === 'normal' && ( + {isDirectView(viewMode) && ( <>
@@ -500,8 +511,8 @@ function PlaygroundControlsContent({ onCheckedChange={setShowAnnotations} /> - {/* Markers use the Normal view's active edit-session editor. */} - {viewMode === 'normal' && ( + {/* Markers use the direct view's active edit-session editor. */} + {isDirectView(viewMode) && ( } label="Markers" @@ -707,6 +718,9 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { const [annotations, setAnnotations] = useState< DiffLineAnnotation[] >(prerenderedDiff.annotations ?? []); + const [fileAnnotations, setFileAnnotations] = useState< + LineAnnotation[] + >([]); const interactionMode: 'select' | 'comment' | 'none' = enableGutterUtility ? 'comment' @@ -717,13 +731,9 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { const edit = mode === 'edit'; // Edits remap annotation line numbers (an Enter above a comment shifts it - // down); onChange hands the remapped set back so the `lineAnnotations` prop - // — and the React-slotted comment content keyed by line number — follows the - // edit. The flushSync matters: the editor renamed the shadow-DOM annotation - // slots during this same keystroke, and until React commits the matching - // light-DOM `slot` attributes the comments project nowhere. A scheduled - // commit lands frames later (blank comments, collapsed rows); a synchronous - // one lands before this task's paint. + // down); onChange writes the remapped collection back to the matching direct + // view. flushSync keeps React's light-DOM annotation slots synchronized with + // the shadow-DOM slot names the editor updates during the same keystroke. const editorRef = useRef | null>(null); const editorOptions = useMemo>( () => ({ @@ -732,24 +742,26 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, onChange: (_file, lineAnnotations) => { - if ( - lineAnnotations != null && - isDiffAnnotationCollection(lineAnnotations) - ) { - flushSync(() => { - setAnnotations(lineAnnotations); - }); + if (lineAnnotations == null) { + return; } + flushSync(() => { + if (isDiffAnnotationCollection(lineAnnotations)) { + setAnnotations(lineAnnotations); + } else if (isFileAnnotationCollection(lineAnnotations)) { + setFileAnnotations(lineAnnotations); + } + }); }, }), [] ); - // Apply (or clear) the demo markers whenever the normal view enters an edit + // Apply (or clear) the demo markers whenever a direct view enters an edit // session or the toggle changes. onAttach supplies the session editor after // attachment completes, so retry until the ref receives it. useEffect(() => { - if (!edit || viewMode !== 'normal') { + if (!edit || !isDirectView(viewMode)) { return; } let frame = 0; @@ -895,6 +907,21 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { }); }, []); + const addFileCommentAtRange = useCallback((range: SelectedLineRange) => { + const lineNumber = range.end; + setFileAnnotations((current) => + current.some((annotation) => annotation.lineNumber === lineNumber) + ? current + : [ + ...current, + { + lineNumber, + metadata: { key: `line-${lineNumber}`, isThread: false }, + }, + ] + ); + }, []); + const handleCancelComment = useCallback( (side: AnnotationSide | undefined, lineNumber: number) => { setAnnotations((prev) => @@ -908,6 +935,17 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { [] ); + const handleCancelFileComment = useCallback( + (_side: AnnotationSide | undefined, lineNumber: number) => { + setFileAnnotations((current) => + current.filter((annotation) => annotation.lineNumber !== lineNumber) + ); + setSelectedRange(null); + setCommittedSelectedRange(null); + }, + [] + ); + // Submitting persists the form in place: the annotation keeps its position // and gains the typed body, which flips its rendering to a comment thread. const handleSubmitComment = useCallback( @@ -925,10 +963,28 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { [] ); + const handleSubmitFileComment = useCallback( + (_side: AnnotationSide | undefined, lineNumber: number, body: string) => { + setFileAnnotations((current) => + current.map((annotation) => + annotation.lineNumber === lineNumber + ? { ...annotation, metadata: { ...annotation.metadata, body } } + : annotation + ) + ); + setSelectedRange(null); + setCommittedSelectedRange(null); + }, + [] + ); + // An open form is an annotation that is neither the seeded thread nor a // submitted comment; it pauses the gutter utility so forms can't stack. - const hasOpenCommentForm = annotations.some( - (ann) => ann.metadata.isThread !== true && ann.metadata.body == null + const hasOpenCommentForm = ( + viewMode === 'file' ? fileAnnotations : annotations + ).some( + (annotation) => + annotation.metadata.isThread !== true && annotation.metadata.body == null ); // The controls expose standalone selection and comments as separate modes. @@ -949,17 +1005,17 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { return () => document.body.classList.remove('overflow-hidden'); }, [isControlsOpen]); - // Editing is controlled only in Normal view. Virtualizer and CodeView own - // per-surface controls, so return Normal to Review when switching views. + // The direct File and FileDiff views share the global edit control. + // Virtualizer and CodeView own per-surface controls instead. const setViewModeAndResetEditor = useCallback((mode: ViewMode) => { setViewMode(mode); - if (mode !== 'normal') setMode('review'); + if (!isDirectView(mode)) setMode('review'); }, []); const [usePrerenderedHTML, setUsePrerenderedHTML] = useState( - () => viewMode === 'normal' + () => viewMode === 'diff' ); - if (usePrerenderedHTML && viewMode !== 'normal') { + if (usePrerenderedHTML && viewMode !== 'diff') { setUsePrerenderedHTML(false); } @@ -1014,7 +1070,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { const effectiveColorMode = colorMode === 'system' ? (resolvedColorScheme ?? 'system') : colorMode; - // Pure rendering options shared by all three view modes. Interaction and + // Pure rendering options shared by every view mode. Interaction and // edit-specific options are layered on per surface below. const renderOptions = useMemo( () => ({ @@ -1058,7 +1114,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { }, [workerPool, renderOptions.theme, renderOptions.lineDiffType]); // CodeView adds its own layout/sticky-header options on top of the shared - // rendering options; its scrollbar styling mirrors the Normal view's. + // rendering options; its scrollbar styling mirrors the direct views. const codeViewOptions = useMemo< CodeViewReactOptions >( @@ -1071,7 +1127,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { [renderOptions] ); - const renderAnnotation = useStableCallback( + const renderDiffAnnotation = useStableCallback( (annotation: DiffLineAnnotation) => { return annotation.metadata.isThread === true ? ( ) => { + return annotation.metadata.body != null ? ( + + handleCancelFileComment(undefined, annotation.lineNumber) + } + /> + ) : ( + + ); + } + ); + + const fileDiffOptions = useMemo( () => ({ ...prerenderedDiff.options, ...renderOptions, @@ -1124,6 +1200,29 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { ] ); + const fileOptions = useMemo>( + () => ({ + ...renderOptions, + unsafeCSS: ITEM_UNSAFE_CSS, + enableLineSelection: canSelectLines, + enableGutterUtility: canUseGutterComments, + onLineSelectionStart: handleLineSelectionChange, + onLineSelectionChange: handleLineSelectionChange, + onLineSelectionEnd: handleLineSelectionEnd, + onGutterUtilityClick: canUseGutterComments + ? addFileCommentAtRange + : undefined, + }), + [ + addFileCommentAtRange, + canSelectLines, + canUseGutterComments, + handleLineSelectionChange, + handleLineSelectionEnd, + renderOptions, + ] + ); + const fileDiff = ( + ); + + const file = ( + ); @@ -1195,8 +1309,10 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
- {viewMode === 'normal' ? ( + {viewMode === 'diff' ? ( fileDiff + ) : viewMode === 'file' ? ( + file ) : viewMode === 'virtualizer' ? ( { @@ -325,7 +325,7 @@ export function PlaygroundCodeView({ ); }, [showAnnotations]); - // Match the Normal view's precedence: an open comment form (neither a + // Match the direct views' precedence: an open comment form (neither a // thread nor a submitted comment) pauses the gutter utility so forms can't // stack. const hasOpenCommentForm = items.some( diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx index e9ee1e28e..d6d80978c 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx @@ -7,6 +7,8 @@ import { type FileDiffOptions, type FileOptions, isDiffAnnotationCollection, + isFileAnnotationCollection, + type LineAnnotation, type SelectedLineRange, } from '@pierre/diffs'; import type { EditorOptions } from '@pierre/diffs/edit'; @@ -54,7 +56,12 @@ export function PlaygroundVirtualizerElementView({ className="border-border rounded-lg border" style={SCROLL_REGION_STYLES} > - + {diffs.map((fileDiff) => ( = { - onAttach(editor) { - editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - }, -}; +interface ElementVirtualizerFileProps { + options: SharedRenderOptions; + enableLineSelection: boolean; + enableGutterComments: boolean; + showAnnotations: boolean; +} + +const EMPTY_FILE_ANNOTATIONS: LineAnnotation[] = + []; -// The long README plain-file surface leading the list. Carries the same -// header Edit toggle as the diffs (the app-level EditProvider creates its -// editor); no comment wiring, since the demo file has no annotations. -function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { +// The long README plain-file surface leading the list. It owns the same edit, +// line-selection, and gutter-comment behavior as each diff below it. +function ElementVirtualizerFile({ + options, + enableLineSelection, + enableGutterComments, + showAnnotations, +}: ElementVirtualizerFileProps) { const [editing, setEditing] = useState(false); + const [annotations, setAnnotations] = useState< + LineAnnotation[] + >([]); + const [selectedLines, setSelectedLines] = useState( + null + ); + + const editorOptions = useMemo>( + () => ({ + onAttach(editor) { + editor.focus({ lineNumber: 'first-visible', preventScroll: true }); + }, + onChange(_file, lineAnnotations) { + if ( + lineAnnotations != null && + isFileAnnotationCollection(lineAnnotations) + ) { + flushSync(() => { + setAnnotations(lineAnnotations); + }); + } + }, + }), + [] + ); + + const addCommentAtRange = useCallback((range: SelectedLineRange) => { + const lineNumber = range.end; + setAnnotations((current) => + current.some((annotation) => annotation.lineNumber === lineNumber) + ? current + : [ + ...current, + { + lineNumber, + metadata: { key: `line-${lineNumber}`, isThread: false }, + }, + ] + ); + }, []); + + const removeCommentAtLine = useCallback( + (_side: AnnotationSide | undefined, lineNumber: number) => { + setAnnotations((current) => + current.filter((annotation) => annotation.lineNumber !== lineNumber) + ); + setSelectedLines(null); + }, + [] + ); + + const submitCommentAtLine = useCallback( + (_side: AnnotationSide | undefined, lineNumber: number, body: string) => { + setAnnotations((current) => + current.map((annotation) => + annotation.lineNumber === lineNumber + ? { ...annotation, metadata: { ...annotation.metadata, body } } + : annotation + ) + ); + setSelectedLines(null); + }, + [] + ); + + useEffect(() => { + if (!showAnnotations) { + setSelectedLines(null); + } + }, [showAnnotations]); + + const hasOpenCommentForm = annotations.some( + (annotation) => annotation.metadata.body == null + ); + const canSelectLines = + enableLineSelection && !enableGutterComments && !hasOpenCommentForm; + const canUseGutterComments = + enableGutterComments && showAnnotations && !hasOpenCommentForm; - const fileOptions = useMemo>( + const fileOptions = useMemo>( () => ({ ...options, stickyHeader: true, unsafeCSS: ITEM_UNSAFE_CSS, + enableLineSelection: canSelectLines, + enableGutterUtility: canUseGutterComments, + onLineSelectionStart: setSelectedLines, + onLineSelectionChange: setSelectedLines, + onLineSelectionEnd: setSelectedLines, + onGutterUtilityClick: canUseGutterComments + ? addCommentAtRange + : undefined, }), - [options] + [options, canSelectLines, canUseGutterComments, addCommentAtRange] + ); + + const renderAnnotation = useStableCallback( + (annotation: LineAnnotation) => { + return annotation.metadata.body != null ? ( + removeCommentAtLine(undefined, annotation.lineNumber)} + /> + ) : ( + + ); + } ); // Must NOT be a stable callback — see ElementVirtualizerDiff's @@ -115,9 +234,12 @@ function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { ); } diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx index dad1e30cc..7bb943cf7 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx @@ -4,6 +4,8 @@ import { type DiffLineAnnotation, type FileDiffMetadata, isDiffAnnotationCollection, + isFileAnnotationCollection, + type LineAnnotation, VirtualizedFile, VirtualizedFileDiff, Virtualizer, @@ -69,6 +71,7 @@ interface VirtualizerAnnotationMetadata { } type VirtualizerAnnotation = DiffLineAnnotation; +type VirtualizerFileAnnotation = LineAnnotation; function annotationKey( index: number, @@ -77,9 +80,13 @@ function annotationKey( return `${index}:${annotation.metadata.key}`; } +function fileAnnotationKey(annotation: VirtualizerFileAnnotation): string { + return `file:${annotation.metadata.key}`; +} + // The "Virtualizer (window)" mode: renders a list of full diffs through the // vanilla Virtualizer using the document/window as the scroll container, so -// the list flows in the page (like the Normal view) rather than scrolling +// the list flows in the page (like the direct views) rather than scrolling // inside its own box. The React wrapper always scrolls inside // its own element — that variant is demoed by // PlaygroundVirtualizerElementView — so this view drives the imperative API @@ -106,7 +113,9 @@ export function PlaygroundVirtualizerView({ const instancesRef = useRef< VirtualizedFileDiff[] >([]); - const fileInstanceRef = useRef(null); + const fileInstanceRef = + useRef | null>(null); + const fileAnnotationsRef = useRef([]); const annotationsRef = useRef([]); const annotationRootsRef = useRef(new Map()); const annotationKeyCounterRef = useRef(0); @@ -139,27 +148,119 @@ export function PlaygroundVirtualizerView({ // The long README plain file leads the window-scroll list (as in // CodeView), driven by the vanilla VirtualizedFile. It carries the same - // header Edit toggle as the diffs below; no comment wiring, since the - // demo file has no annotations. Its container is appended first so it - // sits above the diffs in the page flow. + // header Edit toggle and line interactions as the diffs below. Its + // container is appended first so it sits above the diffs in the page flow. const readmeContainer = document.createElement('diffs-container'); readmeContainer.style.display = 'block'; content.appendChild(readmeContainer); - const readmeEditor = new Editor({ + fileAnnotationsRef.current = []; + const readmeEditor = new Editor({ onAttach(attachedEditor) { attachedEditor.focus({ lineNumber: 'first-visible', preventScroll: true, }); }, + onChange: (_file, lineAnnotations) => { + if ( + lineAnnotations == null || + !isFileAnnotationCollection(lineAnnotations) + ) { + return; + } + const previous = fileAnnotationsRef.current; + if (previous === lineAnnotations) { + return; + } + fileAnnotationsRef.current = lineAnnotations; + const liveKeys = new Set(lineAnnotations.map(fileAnnotationKey)); + for (const annotation of previous) { + const key = fileAnnotationKey(annotation); + if (!liveKeys.has(key)) { + unmountAnnotationRoot(key); + } + } + }, }); const readmeToggle = createEditToggle(); - const fileInstance = new VirtualizedFile( + const rerenderReadmeWithAnnotations = () => { + fileInstance.render({ + file: LONG_README_FILE, + lineAnnotations: [...fileAnnotationsRef.current], + }); + }; + const removeReadmeAnnotation = (annotation: VirtualizerFileAnnotation) => { + fileAnnotationsRef.current = fileAnnotationsRef.current.filter( + (existing) => existing.metadata.key !== annotation.metadata.key + ); + fileInstance.setSelectedLines(null); + rerenderReadmeWithAnnotations(); + unmountAnnotationRoot(fileAnnotationKey(annotation)); + }; + const submitReadmeAnnotation = ( + annotation: VirtualizerFileAnnotation, + body: string + ) => { + fileAnnotationsRef.current = fileAnnotationsRef.current.map((existing) => + existing.metadata.key === annotation.metadata.key + ? { ...existing, metadata: { ...existing.metadata, body } } + : existing + ); + fileInstance.setSelectedLines(null); + rerenderReadmeWithAnnotations(); + }; + const fileInstance = new VirtualizedFile( { ...options, renderHeaderMetadata: () => readmeToggle, stickyHeader: true, unsafeCSS: VIRTUALIZER_CUSTOM_CSS, + enableLineSelection: enableLineSelection && !enableGutterComments, + enableGutterUtility: enableGutterComments && showAnnotations, + onGutterUtilityClick: (range) => { + const lineNumber = range.end; + if ( + fileAnnotationsRef.current.some( + (annotation) => annotation.lineNumber === lineNumber + ) + ) { + return; + } + fileAnnotationsRef.current.push({ + lineNumber, + metadata: { + key: `comment-${annotationKeyCounterRef.current++}`, + }, + }); + rerenderReadmeWithAnnotations(); + }, + renderAnnotation: (annotation) => { + const key = fileAnnotationKey(annotation); + unmountAnnotationRoot(key); + const container = document.createElement('div'); + const root = createRoot(container); + annotationRootsRef.current.set(key, root); + flushSync(() => { + root.render( + annotation.metadata.body != null ? ( + removeReadmeAnnotation(annotation)} + /> + ) : ( + removeReadmeAnnotation(annotation)} + onSubmit={(_side, _lineNumber, body) => + submitReadmeAnnotation(annotation, body) + } + /> + ) + ); + }); + return container; + }, }, virtualizer, undefined, @@ -177,6 +278,7 @@ export function PlaygroundVirtualizerView({ fileInstance.render({ file: LONG_README_FILE, fileContainer: readmeContainer, + lineAnnotations: fileAnnotationsRef.current, }); fileInstanceRef.current = fileInstance; @@ -373,6 +475,7 @@ export function PlaygroundVirtualizerView({ annotationRoots.clear(); instancesRef.current = []; annotationsRef.current = []; + fileAnnotationsRef.current = []; virtualizer.cleanUp(); content.replaceChildren(); }; @@ -399,6 +502,8 @@ export function PlaygroundVirtualizerView({ fileInstance.setOptions({ ...fileInstance.options, ...options, + enableLineSelection: enableLineSelection && !enableGutterComments, + enableGutterUtility: enableGutterComments && showAnnotations, }); } }, [options, enableLineSelection, enableGutterComments, showAnnotations]); @@ -408,6 +513,15 @@ export function PlaygroundVirtualizerView({ if (showAnnotations) { return; } + const fileInstance = fileInstanceRef.current; + if (fileInstance != null) { + fileInstance.setSelectedLines(null); + for (const annotation of fileAnnotationsRef.current) { + unmountAnnotationRoot(fileAnnotationKey(annotation)); + } + fileAnnotationsRef.current = []; + fileInstance.render({ file: LONG_README_FILE, lineAnnotations: [] }); + } instancesRef.current.forEach((instance, index) => { instance.setSelectedLines(null); const annotations = annotationsRef.current[index] ?? []; diff --git a/apps/docs/app/(diffs)/playground/constants.ts b/apps/docs/app/(diffs)/playground/constants.ts index f7f67a9d3..886484a0e 100644 --- a/apps/docs/app/(diffs)/playground/constants.ts +++ b/apps/docs/app/(diffs)/playground/constants.ts @@ -151,15 +151,17 @@ export const PLAYGROUND_MARKERS = [ }, ]; +export const PLAYGROUND_FILE: FileContents = { + name: 'api/users.ts', + contents: NEW_USERS_CONTENT, +}; + const PLAYGROUND_FILE_DIFF = parseDiffFromFile( { name: 'api/users.ts', contents: OLD_USERS_CONTENT, }, - { - name: 'api/users.ts', - contents: NEW_USERS_CONTENT, - } + PLAYGROUND_FILE ); const PLAYGROUND_ANNOTATIONS = [ diff --git a/apps/docs/app/(diffs)/playground/searchParams.ts b/apps/docs/app/(diffs)/playground/searchParams.ts index cb010d594..ea89aced5 100644 --- a/apps/docs/app/(diffs)/playground/searchParams.ts +++ b/apps/docs/app/(diffs)/playground/searchParams.ts @@ -30,7 +30,8 @@ export type PlaygroundLightTheme = (typeof LIGHT_THEMES)[number]; export type PlaygroundDarkTheme = (typeof DARK_THEMES)[number]; const VIEW_MODES = [ - 'normal', + 'diff', + 'file', 'virtualizer', 'virtualizer-element', 'codeview', @@ -49,11 +50,9 @@ const HUNK_SEPARATOR_VALUES = [ const LINE_HOVER_HIGHLIGHTS = ['disabled', 'both', 'number', 'line'] as const; const LINE_MODES = ['select', 'comment', 'none'] as const; -// The rendering surface the playground diff(s) are drawn with. 'normal' is the -// single editable FileDiff; 'virtualizer' renders several diffs with window -// scroll (vanilla Virtualizer); 'virtualizer-element' renders them with the -// React inside its own scroll region; 'codeview' renders a mix -// of diff/file items in CodeView's own scroller. +// The rendering surface used by the playground. 'diff' and 'file' render one +// directly controlled component; the virtualizer modes render scrolling lists; +// 'codeview' renders a mixed list in CodeView's own scroller. export type ViewMode = (typeof VIEW_MODES)[number]; // The editable surface is rendered read-only (Review) or attached to a live @@ -66,7 +65,7 @@ export type PlaygroundLineDiffType = (typeof LINE_DIFF_TYPES)[number]; // Default values for URL param comparison export const DEFAULTS = { - viewMode: 'normal' as ViewMode, + viewMode: 'diff' as ViewMode, diffStyle: 'split', colorMode: 'system', lightTheme: 'pierre-light', @@ -179,9 +178,12 @@ export function parsePlaygroundSearchParams( enableLineSelection, enableGutterUtility, showAnnotations: pickBool(get('annot'), DEFAULTS.annotations), - // Edit mode only exists in the Normal view (other views render per-file - // edit controls instead), so only honor `?edit=edit` when starting there. - mode: viewMode === 'normal' && get('edit') === 'edit' ? 'edit' : 'review', + // The direct File and FileDiff views share one edit control. Scrolling list + // views render their own per-file controls instead. + mode: + (viewMode === 'diff' || viewMode === 'file') && get('edit') === 'edit' + ? 'edit' + : 'review', showMarkers: pickBool(get('markers'), DEFAULTS.markers), selectedRange: parseLineSelection(get('line')), }; diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index db35c8083..f6f4fada3 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -535,8 +535,10 @@ export interface CodeViewOptions options: CodeViewCreateEditorOptions ): DiffsEditor | undefined; /** - * Called when an edited item's document changes, with the owning item - * resolved by CodeView. + * Called whenever the edited document changes, from internal (edit) changes + * or external (CodeViewItem) changes. + * + * Do not feed these changes back into item state until the editing is done. */ onItemEditChange?( item: CodeViewItem, @@ -2015,13 +2017,13 @@ export class CodeView { } /** - * Attach (or lazily create) the editor for a mounted edit-mode item. Called + * Lazily create and attach the editor for a mounted edit-mode item. Called * from the render loop so every mounted item passes through it: fresh * mounts, remounts after virtualization released the item, and items whose * edit flag was just turned on. Editors persist across unmounts, so a * remounted item re-attaches its existing editor and resumes the retained - * document; the renderers keep the host's file/diff data in sync with the - * session so the remount paints the edited text. + * document; the instance retains its private session model so the remount + * paints the edited text without changing the host input. */ private attachItemEditor(item: CodeViewContextItem): void { const { id } = item.item; @@ -3447,6 +3449,7 @@ export class CodeView { this.syncContainerHeight(); const stickyBounds = this.getStickyBounds(windowSpecs); if (stickyBounds == null) { + this.stickyOffset.style.height = `${this.getPagedLayoutTop(windowSpecs.top)}px`; return; } this.applyStickyPositioning(stickyBounds); @@ -4015,24 +4018,14 @@ export class CodeView { } item.top = runningTop; if (item.type === 'diff') { - const fileDiff = item.instance.consumeCodeViewLayoutChanges( - item.item.fileDiff - ); - if (fileDiff != null) { - // Hydration is staged on a clone so layout only changes during this - // render pass, then copied back to preserve the caller's diff - // identity which matches the rest of the architecture of how we - // handle partial hydration - Object.assign(item.item.fileDiff, fileDiff); - } - item.height = item.instance.prepareCodeViewItem( + item.height = item.instance.updateCodeViewLayout( item.item.fileDiff, runningTop, reset, item.item.annotations ?? [] ); } else { - item.height = item.instance.prepareCodeViewItem( + item.height = item.instance.updateCodeViewLayout( item.item.file, runningTop, reset, @@ -4075,14 +4068,14 @@ function prepareItemInstance( ): number { item.instance.cleanUp(true); if (item.type === 'diff') { - return item.instance.prepareCodeViewItem( + return item.instance.updateCodeViewLayout( item.item.fileDiff, item.top, undefined, item.item.annotations ?? [] ); } else { - return item.instance.prepareCodeViewItem( + return item.instance.updateCodeViewLayout( item.item.file, item.top, undefined, diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 99fccac34..c34e54afe 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -40,7 +40,7 @@ import type { SelectedLineRange, ThemeTypes, } from '../types'; -import { areFilesEqual } from '../utils/areFilesEqual'; +import { areFileTargetsEqual } from '../utils/areFileTargetsEqual'; import { areLineAnnotationsEqual } from '../utils/areLineAnnotationsEqual'; import { arePrePropertiesEqual } from '../utils/arePrePropertiesEqual'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; @@ -54,6 +54,7 @@ import { wrapUnsafeCSS, } from '../utils/cssWrappers'; import { getFileRendererOptions } from '../utils/getFileRendererOptions'; +import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; import { guardWebKitScrollDuringRebuild } from '../utils/guardWebKitScrollDuringRebuild'; @@ -130,6 +131,43 @@ interface HydrationSetup { lineAnnotations: LineAnnotation[] | undefined; } +interface PendingEditSessionReplacement { + /** + * The private editable file that was active before the new external file + * arrived. It is compared with the final replacement to determine whether + * the editor can preserve its history. + */ + editSessionFile: FileContents; + /** + * The cache key associated with the current editable file. The editor keeps + * using it until the replacement is rendered and ready to synchronize. + */ + prevExternalCacheKey: string | undefined; +} + +interface PendingPersistedDocumentRestore { + editSessionFile: FileContents; + previousContents: string; +} + +function createEditSessionFile(file: FileContents): FileContents { + const editSessionFile = { ...file }; + delete editSessionFile.cacheKey; + return editSessionFile; +} + +function shouldResetUndoState( + previousFile: FileContents, + nextFile: FileContents +): boolean { + const previousLanguage = + previousFile.lang ?? getFiletypeFromFileName(previousFile.name); + const nextLanguage = nextFile.lang ?? getFiletypeFromFileName(nextFile.name); + return ( + previousFile.name !== nextFile.name || previousLanguage !== nextLanguage + ); +} + let instanceId = -1; export class File< @@ -176,6 +214,14 @@ export class File< protected managersDirty = false; public file: FileContents | undefined; + private editSessionFile: FileContents | undefined; + private pendingEditSessionReplacement: + | PendingEditSessionReplacement + | undefined; + private pendingPersistedDocumentRestore: + | PendingPersistedDocumentRestore + | undefined; + protected renderedFile: FileContents | undefined; protected renderRange: RenderRange | undefined; protected enabled = true; @@ -212,8 +258,87 @@ export class File< }); } - public __getCurrentFile(): FileContents | undefined { - return this.file; + // Return the newest file this component intends to display. Once editing + // starts, the private edit-session file owns that state. + protected getLatestFile( + file: FileContents | undefined = this.file + ): FileContents | undefined { + return this.editSessionFile ?? file; + } + + // Return the file that produced the DOM currently owned by this instance. + protected getRenderedFile(): FileContents | undefined { + return this.renderedFile; + } + + protected updateExternalFile(incomingFile: FileContents): boolean { + if (areFileTargetsEqual(this.file, incomingFile)) { + return false; + } + + const { + file: previousExternalFile, + pendingEditSessionReplacement: pendingReplacement, + } = this; + const previousEditSessionFile = + pendingReplacement?.editSessionFile ?? this.editSessionFile; + const prevExternalCacheKey = + pendingReplacement?.prevExternalCacheKey ?? + previousExternalFile?.cacheKey; + + this.file = incomingFile; + this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; + + if (previousEditSessionFile != null) { + this.pendingEditSessionReplacement = { + editSessionFile: previousEditSessionFile, + prevExternalCacheKey, + }; + this.installExternalEditSession(incomingFile); + } else if (this.editor != null) { + this.createInitialEditSession(this.editor, incomingFile); + } else { + this.editSessionFile = undefined; + } + return true; + } + + private installExternalEditSession(externalFile: FileContents): void { + const editSessionFile = createEditSessionFile(externalFile); + this.editSessionFile = editSessionFile; + this.fileRenderer.beginEditSession(editSessionFile, externalFile); + } + + /** + * Create the first private editable file for an editor attachment. When a + * persisted document has different text, initialize the private file with + * that text so the restored editor document and rendered rows start in sync. + */ + private createInitialEditSession( + editor: DiffsEditor, + externalFile: FileContents + ): void { + const editSessionFile = createEditSessionFile(externalFile); + const cachedContents = editor.__getCachedDocumentContents?.(externalFile); + const restoredPersistedContents = + cachedContents != null && cachedContents !== externalFile.contents; + + if (restoredPersistedContents) { + editSessionFile.contents = cachedContents; + this.pendingPersistedDocumentRestore = { + editSessionFile, + previousContents: externalFile.contents, + }; + } else { + this.pendingPersistedDocumentRestore = undefined; + } + + this.editSessionFile = editSessionFile; + this.fileRenderer.beginEditSession( + editSessionFile, + restoredPersistedContents ? undefined : externalFile + ); } public onThemeChange(): void { @@ -392,6 +517,10 @@ export class File< this.fileRenderer.cleanUp(); this.workerManager = undefined; this.file = undefined; + this.editSessionFile = undefined; + this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; + this.renderedFile = undefined; } this.enabled = false; } @@ -503,6 +632,7 @@ export class File< return; } this.fileRenderer.hydrate(file); + this.renderedFile = file; this.renderAnnotations(); this.renderGutterUtility(); this.injectUnsafeCSS(); @@ -511,7 +641,7 @@ export class File< } public getOrCreateLineCache( - file: FileContents | undefined = this.file + file: FileContents | undefined = this.getLatestFile() ): string[] { return file != null ? this.fileRenderer.getOrCreateLineCache(file) @@ -525,46 +655,70 @@ export class File< } private syncRenderViewToEditor(): void { - const editor = this.editor; - const fileContainer = this.fileContainer; - const file = this.file; - const lineAnnotations = this.lineAnnotations; - const renderRange = this.renderRange; - if (editor != null && fileContainer != null && file != null) { - void this.fileRenderer.initializeHighlighter().then((highlighter) => { - if ( - !this.enabled || - this.editor !== editor || - this.fileContainer !== fileContainer || - this.file !== file - ) { - return; - } - editor.__syncRenderView( - highlighter, - fileContainer, - file, - lineAnnotations, - renderRange - ); - }); + const { editor, fileContainer, lineAnnotations, renderRange } = this; + const file = this.getLatestFile(); + if (editor == null || fileContainer == null || file == null) { + return; } + void this.fileRenderer.initializeHighlighter().then((highlighter) => { + if ( + !this.enabled || + this.editor !== editor || + this.fileContainer !== fileContainer || + this.getLatestFile() !== file + ) { + return; + } + const { pendingEditSessionReplacement: replacement } = this; + const externalFile = this.file; + const externalDocument = + replacement != null && + externalFile != null && + replacement.editSessionFile !== file; + const resetHistory = externalDocument + ? shouldResetUndoState(replacement.editSessionFile, externalFile) + : false; + const externalCacheKey = + replacement != null && !externalDocument + ? replacement.prevExternalCacheKey + : externalFile?.cacheKey; + const pendingRestore = this.pendingPersistedDocumentRestore; + const restoredDocument = + pendingRestore?.editSessionFile === file + ? pendingRestore.previousContents + : undefined; + if (externalDocument) { + this.pendingEditSessionReplacement = undefined; + } + if (restoredDocument != null) { + this.pendingPersistedDocumentRestore = undefined; + } + editor.__syncRenderView({ + highlighter, + fileContainer, + file, + externalCacheKey, + lineAnnotations, + renderRange, + externalDocument, + resetHistory, + restoredDocument, + }); + }); } public attachEditor(editor: DiffsEditor): () => void { this.editor?.cleanUp(); this.editor = editor; - this.fileRenderer.beginEditSession(); - const preparedFile = - this.file == null ? undefined : editor.__prepareFile?.(this.file); - if (preparedFile !== undefined && preparedFile !== this.file) { - this.renderPreparedFile({ - file: preparedFile, - forceRender: true, - preventEmit: true, - renderRange: this.renderRange, - }); - } else if (this.fileRenderer.editorRenderReady()) { + if (this.editSessionFile == null && this.file != null) { + this.createInitialEditSession(editor, this.file); + } else { + this.fileRenderer.beginEditSession(this.editSessionFile); + } + if (this.fileRenderer.editorRenderReady()) { + if (this.fileRenderer.fileCache === this.editSessionFile) { + this.renderedFile = this.editSessionFile; + } this.syncRenderViewToEditor(); } else { // The current markup is missing the editor's token metadata, or its @@ -583,11 +737,17 @@ export class File< textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[] ): void { + const editSessionFile = this.editSessionFile; + if (editSessionFile == null) { + throw new Error( + 'File.applyDocumentChange: requires an active edit session' + ); + } + this.fileRenderer.beginEditSession(editSessionFile); this.fileRenderer.applyDocumentChange(textDocument); if ( newLineAnnotations != null && - newLineAnnotations !== this.lineAnnotations && - this.file != null + newLineAnnotations !== this.lineAnnotations ) { this.setLineAnnotations(newLineAnnotations); this.fileRenderer.setLineAnnotations(this.lineAnnotations); @@ -602,6 +762,13 @@ export class File< lineCountChangeInFlight?: boolean; } ): void { + const { editSessionFile } = this; + if (editSessionFile == null) { + throw new Error( + 'File.updateRenderCache: requires an active edit session' + ); + } + this.fileRenderer.beginEditSession(editSessionFile); this.fileRenderer.updateRenderCache( dirtyLines, themeType, @@ -609,22 +776,7 @@ export class File< ); } - public render(props: FileRenderProps): boolean { - if (!this.enabled) { - throw new Error( - 'File.render: attempting to call render after cleaned up' - ); - } - - const file = this.editor?.__prepareFile?.(props.file) ?? props.file; - return this.renderPreparedFile( - file === props.file ? props : { ...props, file } - ); - } - - // Renders a file whose persisted document has already been restored. The - // virtualized subclass overrides this phase so layout uses the same file. - protected renderPreparedFile({ + public render({ file, fileContainer, forceRender = false, @@ -634,6 +786,12 @@ export class File< lineAnnotations, renderRange, }: FileRenderProps): boolean { + if (!this.enabled) { + throw new Error( + 'File.render: attempting to call render after cleaned up' + ); + } + // postpone background tokenizing to next frame for avoiding UI freeze // during render this.editor?.__postponeBgTokenizeToNextFrame(); @@ -647,9 +805,11 @@ export class File< (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) ? lineAnnotations !== this.lineAnnotations : false; - const didFileChange = - !areFilesEqual(this.file, file) || - this.fileRenderer.hasUnkeyedFileContentsChanged(file); + const didFileChange = !areFileTargetsEqual(this.file, file); + if (didFileChange) { + this.updateExternalFile(file); + } + const latestFile = this.getLatestFile(file) ?? file; if ( !collapsed && !forceRender && @@ -665,7 +825,6 @@ export class File< if (didFileChange) { this.cachedHeaderHTML = undefined; } - this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); if (lineAnnotations != null) { @@ -697,7 +856,7 @@ export class File< try { const fileResult = this.fileRenderer.renderFile( - file, + latestFile, EMPTY_RENDER_RANGE ); if (fileResult != null) { @@ -709,8 +868,13 @@ export class File< ); } if (fileResult?.headerAST != null) { - this.applyHeaderToDOM(fileResult.headerAST, fileContainer); + this.applyHeaderToDOM( + fileResult.headerAST, + fileContainer, + fileResult.file + ); } + this.renderedFile = fileResult?.file ?? latestFile; this.injectUnsafeCSS(); } catch (error: unknown) { if (disableErrorHandling) { @@ -733,11 +897,20 @@ export class File< !this.canPartiallyRender( forceRender, annotationsChanged, - didFileChange || themeChanged + didFileChange || + themeChanged || + !areFileTargetsEqual(this.renderedFile, latestFile) ) || - !this.applyPartialRender(previousRenderRange, nextRenderRange) + !this.applyPartialRender( + latestFile, + previousRenderRange, + nextRenderRange + ) ) { - const fileResult = this.fileRenderer.renderFile(file, nextRenderRange); + const fileResult = this.fileRenderer.renderFile( + latestFile, + nextRenderRange + ); if (fileResult == null) { if (this.workerManager?.isInitialized() === false) { void this.workerManager.initialize().then(() => this.rerender()); @@ -751,9 +924,14 @@ export class File< fileResult.baseThemeType ); if (fileResult.headerAST != null) { - this.applyHeaderToDOM(fileResult.headerAST, fileContainer); + this.applyHeaderToDOM( + fileResult.headerAST, + fileContainer, + fileResult.file + ); } this.applyFullRender(fileResult, pre); + this.renderedFile = fileResult.file; } this.applyBuffers(pre, nextRenderRange); @@ -1139,15 +1317,16 @@ export class File< } private applyPartialRender( + file: FileContents, previousRenderRange: RenderRange | undefined, renderRange: RenderRange | undefined ): boolean { if (previousRenderRange == null || renderRange == null) { return false; } - const { file, code } = this; + const { code } = this; const columns = code != null ? this.getColumns(code) : undefined; - if (file == null || code == null || columns == null) { + if (code == null || columns == null) { return false; } @@ -1397,10 +1576,9 @@ export class File< private applyHeaderToDOM( headerAST: HASTElement, - container: HTMLElement + container: HTMLElement, + file: FileContents ): void { - const { file } = this; - if (file == null) return; this.cleanupErrorWrapper(); this.placeHolder?.remove(); this.placeHolder = undefined; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index e924a229e..9502c2ae6 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -62,6 +62,7 @@ import type { ThemeTypes, } from '../types'; import { areDiffLineAnnotationsEqual } from '../utils/areDiffLineAnnotationsEqual'; +import { areDiffTargetsEqual } from '../utils/areDiffTargetsEqual'; import { areFilesEqual } from '../utils/areFilesEqual'; import { areHunkDataEqual } from '../utils/areHunkDataEqual'; import { arePrePropertiesEqual } from '../utils/arePrePropertiesEqual'; @@ -80,9 +81,11 @@ import { captureExpansionAnchors, finishEditSessionForDiff, rebuildExpansionFromAnchors, + rebuildSessionHunks, } from '../utils/editSessionHunks'; import { getDiffFileInput } from '../utils/getDiffFileInput'; import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions'; +import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { getHunkSideStartBoundary } from '../utils/getHunkSideBoundaries'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; @@ -98,6 +101,7 @@ import { isSafari } from '../utils/platform'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { getMeasuredScrollbarGutter } from '../utils/scrollbarGutter'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; +import { splitFileContents } from '../utils/splitFileContents'; import { getExpandedRegion, getHunkAdditionLineRange, @@ -122,6 +126,25 @@ type DeferredEditorActiveLineWrite = [ options: EditorActiveLineOptions | undefined, ]; +interface PendingEditSessionReplacement { + /** + * The private editable diff that was active before the new external diff + * arrived. It remains active while a partial replacement is loading and is + * compared with the completed replacement to determine history compatibility. + */ + editSessionDiff: FileDiffMetadata; + /** + * The cache key associated with the current editable diff. + * The editor continues using it until the replacement is ready. + */ + prevExternalCacheKey: string | undefined; +} + +interface PendingPersistedDocumentRestore { + editSessionDiff: FileDiffMetadata; + previousContents: string; +} + function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { return ( fileDiff.isPartial && @@ -131,6 +154,40 @@ function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { ); } +// Edit sessions incrementally clone the diff as needed while editing, +// initially we start with a top level fast clone +function createEditSessionDiff(fileDiff: FileDiffMetadata): FileDiffMetadata { + const editSessionDiff = { ...fileDiff }; + delete editSessionDiff.cacheKey; + return editSessionDiff; +} + +function shouldResetUndoState( + prevDiff: FileDiffMetadata, + nextDiff: FileDiffMetadata +): boolean { + if (prevDiff.isPartial || nextDiff.isPartial) { + throw new Error( + 'FileDiff.shouldResetEditorForExternalDiff: diffs must be fully hydrated' + ); + } + const prevLanguage = prevDiff.lang ?? getFiletypeFromFileName(prevDiff.name); + const nextLanguage = nextDiff.lang ?? getFiletypeFromFileName(nextDiff.name); + const prevHasOldFile = prevDiff.type !== 'new'; + const nextHasOldFile = nextDiff.type !== 'new'; + if ( + prevDiff.name !== nextDiff.name || + prevLanguage !== nextLanguage || + prevHasOldFile !== nextHasOldFile || + prevDiff.deletionLines.length !== nextDiff.deletionLines.length + ) { + return true; + } + return prevDiff.deletionLines.some( + (line, index) => line !== nextDiff.deletionLines[index] + ); +} + export interface FileDiffRenderBaseProps { fileDiff?: FileDiffMetadata; deferManagers?: boolean; @@ -222,6 +279,7 @@ interface TrimColumnsToOverlapProps { } interface ApplyPartialRenderProps { + fileDiff: FileDiffMetadata; previousRenderRange: RenderRange | undefined; renderRange: RenderRange | undefined; } @@ -236,6 +294,12 @@ type HydrationSetup = { lineAnnotations: DiffLineAnnotation[] | undefined; } & MaybeDiffFileInput; +interface HeaderCache { + lastRenderedHTML: string | undefined; + html: string | undefined; + fileDiff: FileDiffMetadata | undefined; +} + let instanceId = -1; export class FileDiff< @@ -285,11 +349,22 @@ export class FileDiff< protected deletionFile?: FileContents | null; protected additionFile?: FileContents | null; public fileDiff: FileDiffMetadata | undefined; + private editSessionDiff: FileDiffMetadata | undefined; + private pendingEditSessionReplacement: + | PendingEditSessionReplacement + | undefined; + private pendingPersistedDocumentRestore: + | PendingPersistedDocumentRestore + | undefined; + protected renderedDiff: FileDiffMetadata | undefined; protected renderRange: RenderRange | undefined; protected pendingFiles: PendingFileLoad | undefined; protected appliedPreAttributes: PrePropertiesConfig | undefined; - protected lastRenderedHeaderHTML: string | undefined; - protected cachedHeaderHTML: string | undefined; + protected headerCache: HeaderCache = { + lastRenderedHTML: undefined, + html: undefined, + fileDiff: undefined, + }; protected lastRowCount: number | undefined; private mounted = false; @@ -352,9 +427,24 @@ export class FileDiff< lineNumber: number, side: SelectionSide = 'additions' ) => { - // use the fileDiff from the hunksRenderer if it exists, it maybe updated - // by the host - const fileDiff = this.fileDiffCache; + return this.getLineIndexForDiff( + this.getDiffForLineIndex(), + lineNumber, + side + ); + }; + + protected getDiffForLineIndex(): FileDiffMetadata | undefined { + return this.getRenderedDiff(); + } + + // Resolve source lines against the same diff that produced the rendered + // rows. During an asynchronous replacement, that can be the previous diff. + protected getLineIndexForDiff( + fileDiff: FileDiffMetadata | undefined, + lineNumber: number, + side: SelectionSide + ): [number, number] | undefined { if (fileDiff == null) { return undefined; } @@ -430,7 +520,7 @@ export class FileDiff< return undefined; } return [targetUnifiedIndex, targetSplitIndex]; - }; + } // FIXME(amadeus): This is a bit of a looming issue that I'll need to resolve: // * Do we publicly allow merging of options or do we have individualized setters? @@ -442,7 +532,7 @@ export class FileDiff< public setOptions(options: FileDiffOptions | undefined): void { if (options == null) return; this.options = options; - this.cachedHeaderHTML = undefined; + this.clearReusableHeader(); this.hunksRenderer.setOptions(this.getHunksRendererOptions(options)); this.syncInteractionOptions(); } @@ -647,7 +737,9 @@ export class FileDiff< this.managersDirty = false; this.workerManager?.unsubscribeToThemeChanges(this); this.renderRange = undefined; - this.pendingFiles = undefined; + if (!recycle) { + this.pendingFiles = undefined; + } // Clean up the elements if (!this.isContainerManaged) { @@ -676,9 +768,9 @@ export class FileDiff< this.headerCustom = undefined; this.placeHolder?.remove(); this.placeHolder = undefined; - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; if (!recycle) { - this.cachedHeaderHTML = undefined; + this.clearReusableHeader(); } this.errorWrapper?.remove(); this.errorWrapper = undefined; @@ -697,10 +789,13 @@ export class FileDiff< this.workerManager = undefined; // Clean up the data this.fileDiff = undefined; + this.editSessionDiff = undefined; + this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; + this.renderedDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; } - if (this.refreshViewTimeout != null) { clearTimeout(this.refreshViewTimeout); this.refreshViewTimeout = undefined; @@ -859,6 +954,7 @@ export class FileDiff< this.syncInteractionOptions(); this.hunksRenderer.hydrate(this.fileDiff); + this.renderedDiff = this.fileDiff; // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); this.renderAnnotations(); @@ -926,10 +1022,19 @@ export class FileDiff< return; } - this.pendingFiles = { + const promise = this.loadFilesForDiff(fileDiff, loadDiffFiles); + const pendingFiles: PendingFileLoad = (this.pendingFiles = { fileDiff, - promise: this.loadFilesForDiff(fileDiff, loadDiffFiles), + promise, + }); + // Track the exact request object so an older completion for the same diff + // cannot clear a newer request. + const clearPendingFiles = (): void => { + if (this.pendingFiles === pendingFiles) { + this.pendingFiles = undefined; + } }; + pendingFiles.promise = promise.finally(clearPendingFiles); } private async loadFilesForDiff( @@ -948,10 +1053,6 @@ export class FileDiff< throw error; } console.error(error); - } finally { - if (this.pendingFiles?.fileDiff === fileDiff) { - this.pendingFiles = undefined; - } } } @@ -964,6 +1065,10 @@ export class FileDiff< } hydratePartialDiff('merge', expectedDiff, files); this.setHydratedState(files); + if (this.startHydratedEditSession(expectedDiff)) { + this.rerender(); + return; + } await awaitWithTimeout(() => this.primeHighlightCache(expectedDiff)); if (!this.enabled || this.fileDiff !== expectedDiff) { return; @@ -971,6 +1076,117 @@ export class FileDiff< this.rerender(); } + // Start editing from an already hydrated `this.fileDiff`. The keyless + // shallow copy shares nested data until an edit needs to change it. + protected startHydratedEditSession(expectedDiff: FileDiffMetadata): boolean { + if (expectedDiff.isPartial) { + throw new Error( + 'FileDiff.startHydratedEditSession: diffs cannot be partial for editing' + ); + } + if (this.fileDiff !== expectedDiff) { + return false; + } + if (this.pendingEditSessionReplacement != null) { + this.installExternalEditSession(expectedDiff); + return true; + } + const { editor } = this; + if (editor == null || this.editSessionDiff != null) { + return false; + } + this.createInitialEditSession(editor, expectedDiff); + return true; + } + + // Store a new caller-owned baseline and, when editing, retain the previous + // session only until a partial replacement is hydrated. Full replacements + // can install their new private session immediately. + protected updateExternalDiff( + incomingExternalDiff: FileDiffMetadata + ): boolean { + if (areDiffTargetsEqual(this.fileDiff, incomingExternalDiff)) { + return false; + } + + const { + fileDiff: prevFileDiff, + pendingEditSessionReplacement: pendingReplacement, + } = this; + const editSessionDiff = + pendingReplacement?.editSessionDiff ?? this.editSessionDiff; + const prevExternalCacheKey = + pendingReplacement?.prevExternalCacheKey ?? prevFileDiff?.cacheKey; + + this.fileDiff = incomingExternalDiff; + this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; + if (editSessionDiff != null) { + this.pendingEditSessionReplacement = { + editSessionDiff, + prevExternalCacheKey, + }; + if (incomingExternalDiff.isPartial) { + this.loadFilesIfNecessary(); + } else { + this.installExternalEditSession(incomingExternalDiff); + } + } else if (this.editor != null && !incomingExternalDiff.isPartial) { + this.createInitialEditSession(this.editor, incomingExternalDiff); + } + return true; + } + + private installExternalEditSession(externalDiff: FileDiffMetadata): void { + const sessionDiff = createEditSessionDiff(externalDiff); + this.editSessionDiff = sessionDiff; + this.hunksRenderer.beginEditSession(sessionDiff, externalDiff); + } + + /** + * Create the first private editable diff for an editor attachment. When a + * persisted document has different text, rebuild the private diff from that + * text so the restored editor document and rendered rows start in sync. + */ + private createInitialEditSession( + editor: DiffsEditor, + externalDiff: FileDiffMetadata + ): void { + const editSessionDiff = createEditSessionDiff(externalDiff); + const cachedContents = editor.__getCachedDocumentContents?.(externalDiff); + const restoredPersistedContents = + cachedContents != null && + cachedContents !== externalDiff.additionLines.join(''); + + if (restoredPersistedContents) { + const { + collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + } = this.options; + const anchors = captureExpansionAnchors( + externalDiff, + this.hunksRenderer.getExpandedHunksMap(), + collapsedContextThreshold + ); + editSessionDiff.additionLines = splitFileContents(cachedContents); + rebuildSessionHunks(editSessionDiff, this.options.parseDiffOptions); + this.hunksRenderer.setExpandedHunksMap( + rebuildExpansionFromAnchors(editSessionDiff, anchors) + ); + this.pendingPersistedDocumentRestore = { + editSessionDiff, + previousContents: externalDiff.additionLines.join(''), + }; + } else { + this.pendingPersistedDocumentRestore = undefined; + } + + this.editSessionDiff = editSessionDiff; + this.hunksRenderer.beginEditSession( + editSessionDiff, + restoredPersistedContents ? undefined : externalDiff + ); + } + protected setHydratedState(files: LoadedPartialDiffContents): void { this.deletionFile = files.oldFile; this.additionFile = files.newFile; @@ -989,9 +1205,6 @@ export class FileDiff< renderRange, ...fileInputProps }: FileDiffRenderProps): boolean { - const fileInput = getDiffFileInput(fileInputProps, 'FileDiff.render'); - const oldFile = fileInput?.oldFile; - const newFile = fileInput?.newFile; if (!this.enabled) { // NOTE(amadeus): May need to be a silent failure? Making it loud for now // to better understand it @@ -1000,6 +1213,10 @@ export class FileDiff< ); } + const fileInput = getDiffFileInput(fileInputProps, 'FileDiff.render'); + const oldFile = fileInput?.oldFile; + const newFile = fileInput?.newFile; + // postpone background tokenizing to next frame for avoiding UI freeze // during render this.editor?.__postponeBgTokenizeToNextFrame(); @@ -1016,24 +1233,8 @@ export class FileDiff< hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || !areOptionalFilesEqual(newFile, this.additionFile)); - const { fileDiffCache: sessionDiff } = this; - if ( - fileDiff != null && - this.editor != null && - sessionDiff?.editSessionDirty === true && - fileDiff.cacheKey === sessionDiff.cacheKey && - fileDiff.name === sessionDiff.name && - fileDiff.lang === sessionDiff.lang && - (fileDiff.cacheKey !== undefined || - fileDiff.prevName === sessionDiff.prevName) - ) { - // Preserve dirty metadata only for the same editor target. Unkeyed diffs - // also compare the previous path because no cache key distinguishes it. - // This is a temporary workaround for edit vs render content change - // hardening - fileDiff = sessionDiff; - } - let diffDidChange = fileDiff != null && fileDiff !== this.fileDiff; + let diffDidChange = + fileDiff != null && !areDiffTargetsEqual(fileDiff, this.fileDiff); const annotationsChanged = lineAnnotations != null && (lineAnnotations.length > 0 || this.lineAnnotations.length > 0) @@ -1048,7 +1249,7 @@ export class FileDiff< !themeChanged && // If using the fileDiff API, lets check to see if they are equal to // avoid doing work - ((fileDiff != null && fileDiff === this.fileDiff) || + ((fileDiff != null && !diffDidChange) || // If using the oldFile/newFile API then lets check to see if they are // equal (fileDiff == null && !filesDidChange)) @@ -1083,30 +1284,32 @@ export class FileDiff< this.additionFile = undefined; } - if (fileDiff != null) { - this.fileDiff = fileDiff; + if (fileDiff != null && diffDidChange) { + this.updateExternalDiff(fileDiff); } else if (nextParsedFileDiff != null) { diffDidChange = true; - this.fileDiff = nextParsedFileDiff; + this.updateExternalDiff(nextParsedFileDiff); } if (diffDidChange) { - this.cachedHeaderHTML = undefined; + this.clearReusableHeader(); } if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } - if (this.fileDiff == null) { + + const latestDiff = this.getLatestDiff(); + if (latestDiff == null) { return false; } // Backstop for sessions that ended without their exit hook running (e.g. // session-shaped metadata reused after a host teardown): restore // recompute-shaped hunks before rendering. if ( - this.fileDiff.editSessionDirty === true && + latestDiff.editSessionDirty === true && this.shouldSelfHealEditSession() ) { - finishEditSessionForDiff(this.fileDiff, this.options.parseDiffOptions); + finishEditSessionForDiff(latestDiff, this.options.parseDiffOptions); void this.hunksRenderer.refreshHighlightedResult(); } if (expandUnchanged) { @@ -1125,7 +1328,7 @@ export class FileDiff< if (this.headerElement != null) { this.headerElement.remove(); this.headerElement = undefined; - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; } this.clearHeaderSlots(); } @@ -1141,7 +1344,7 @@ export class FileDiff< try { const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + latestDiff, EMPTY_RENDER_RANGE ); if (hunksResult != null) { @@ -1153,9 +1356,14 @@ export class FileDiff< ); } if (hunksResult?.headerElement != null) { - this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); + this.applyHeaderToDOM( + hunksResult.headerElement, + fileContainer, + hunksResult.fileDiff + ); } this.renderSeparators([]); + this.renderedDiff = hunksResult?.fileDiff ?? latestDiff; this.injectUnsafeCSS(); } catch (error: unknown) { if (disableErrorHandling) { @@ -1180,9 +1388,13 @@ export class FileDiff< this.canPartiallyRender( forceRender, annotationsChanged, - filesDidChange || diffDidChange || themeChanged + filesDidChange || + diffDidChange || + themeChanged || + !areDiffTargetsEqual(this.renderedDiff, latestDiff) ) && this.applyPartialRender({ + fileDiff: latestDiff, previousRenderRange, renderRange: nextRenderRange, }); @@ -1190,7 +1402,7 @@ export class FileDiff< // If we were unable to partially render, perform a full render if (!didPartiallyRender) { const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + latestDiff, nextRenderRange ); if (hunksResult == null) { @@ -1210,7 +1422,11 @@ export class FileDiff< ); if (hunksResult.headerElement != null) { - this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); + this.applyHeaderToDOM( + hunksResult.headerElement, + fileContainer, + hunksResult.fileDiff + ); } if ( hunksResult.additionsContentAST != null || @@ -1223,6 +1439,7 @@ export class FileDiff< this.pre = undefined; } this.renderSeparators(hunksResult.hunkData); + this.renderedDiff = hunksResult.fileDiff; } this.applyBuffers(pre, nextRenderRange); this.injectUnsafeCSS(); @@ -1279,40 +1496,77 @@ export class FileDiff< onPostRender?.(fileContainer, this, phase); } - protected get fileDiffCache(): FileDiffMetadata | undefined { - return this.hunksRenderer.diffCache ?? this.fileDiff; + // Return the newest diff this component intends to display. An active edit + // session owns that state instead of the caller-provided diff. + protected getLatestDiff( + fileDiff: FileDiffMetadata | undefined = this.fileDiff + ): FileDiffMetadata | undefined { + return this.editSessionDiff ?? fileDiff; + } + + // Return the diff that produced the DOM currently owned by this instance. + // It can trail getLatestDiff while replacement highlighting is pending. + protected getRenderedDiff(): FileDiffMetadata | undefined { + return this.renderedDiff; } private syncRenderViewToEditor(): void { - const editor = this.editor; - const fileContainer = this.fileContainer; - const fileDiff = this.fileDiffCache; - const lineAnnotations = this.lineAnnotations; + const { editor, fileContainer, lineAnnotations } = this; const renderRange = this.computeEditorRenderRange(this.renderRange); + const fileDiff = this.getLatestDiff(); if ( - editor != null && - fileContainer != null && - fileDiff != null && - !fileDiff.isPartial + editor == null || + fileContainer == null || + fileDiff == null || + fileDiff.isPartial ) { - void this.hunksRenderer.initializeHighlighter().then((highlighter) => { - if ( - !this.enabled || - this.editor !== editor || - this.fileContainer !== fileContainer || - this.fileDiffCache !== fileDiff - ) { - return; - } - editor.__syncRenderView( - highlighter, - fileContainer, - fileDiff, - lineAnnotations, - renderRange - ); - }); + return; } + void this.hunksRenderer.initializeHighlighter().then((highlighter) => { + if ( + !this.enabled || + this.editor !== editor || + this.fileContainer !== fileContainer || + this.getLatestDiff() !== fileDiff + ) { + return; + } + const { pendingEditSessionReplacement: replacement } = this; + const externalDiff = this.fileDiff; + const externalDocument = + replacement != null && + externalDiff != null && + replacement.editSessionDiff !== fileDiff; + const resetHistory = externalDocument + ? shouldResetUndoState(replacement.editSessionDiff, externalDiff) + : false; + const externalCacheKey = + replacement != null && !externalDocument + ? replacement.prevExternalCacheKey + : this.fileDiff?.cacheKey; + const pendingRestore = this.pendingPersistedDocumentRestore; + const restoredDocument = + pendingRestore?.editSessionDiff === fileDiff + ? pendingRestore.previousContents + : undefined; + if (externalDocument) { + this.pendingEditSessionReplacement = undefined; + } + if (restoredDocument != null) { + this.pendingPersistedDocumentRestore = undefined; + } + editor.__syncRenderView({ + highlighter, + fileContainer, + fileDiff, + externalCacheKey, + lineAnnotations, + renderRange, + externalDocument, + resetHistory, + restoredDocument, + }); + }); } // The stored render range is in rendered-row units for the windowed AST @@ -1324,7 +1578,7 @@ export class FileDiff< private computeEditorRenderRange( renderRange: RenderRange | undefined ): RenderRange | undefined { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getLatestDiff(); if ( renderRange == null || fileDiff == null || @@ -1378,13 +1632,45 @@ export class FileDiff< } this.editor?.cleanUp(); this.editor = editor; - this.hunksRenderer.beginEditSession(); + const { + fileDiff: externalDiff, + pendingEditSessionReplacement: pendingReplacement, + } = this; + if ( + pendingReplacement != null && + externalDiff != null && + !externalDiff.isPartial && + this.editSessionDiff === pendingReplacement.editSessionDiff + ) { + this.installExternalEditSession(externalDiff); + } + const initialExternalDiff = + this.editSessionDiff == null && + externalDiff != null && + !externalDiff.isPartial + ? externalDiff + : undefined; + if (initialExternalDiff != null) { + this.createInitialEditSession(editor, initialExternalDiff); + } else { + this.hunksRenderer.beginEditSession(this.editSessionDiff); + } // The editor sync below refuses partial diffs (it needs the full file // contents); kick off hydration so the loaded re-render re-runs it. if (this.fileDiff?.isPartial === true) { this.loadFilesIfNecessary(); } if (this.hunksRenderer.editorRenderReady()) { + const { editSessionDiff } = this; + // Compatible markup can be reused without repainting. Once the renderer + // transfers that cache to the private session, the existing DOM belongs + // to the session as well. + if ( + editSessionDiff != null && + this.hunksRenderer.diffCache === editSessionDiff + ) { + this.renderedDiff = editSessionDiff; + } this.syncRenderViewToEditor(); } else { // The current markup is missing the editor's token metadata, or its @@ -1392,12 +1678,12 @@ export class FileDiff< // syncs the render view once it paints. this.rerender(); } - return (recycle?: boolean) => { + return (recycle: boolean = false) => { this.editor = undefined; // A recycle detach is a virtualized unmount mid-session: the session // continues on remount, so hunks stay session-shaped. Only a genuine // end runs the exit recompute. - if (recycle !== true) { + if (!recycle) { this.finishEditSession(); } }; @@ -1422,7 +1708,7 @@ export class FileDiff< * ran. */ public completeEditSession(): boolean { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getLatestDiff(); if (fileDiff == null || fileDiff.editSessionDirty !== true) { return false; } @@ -1447,15 +1733,15 @@ export class FileDiff< textDocument: DiffsTextDocument, newLineAnnotations?: DiffLineAnnotation[] ): void { - this.hunksRenderer.applyDocumentChange(textDocument); - const fileDiff = this.hunksRenderer.diffCache; - if (fileDiff != null) { - const cacheKey = this.fileDiff?.cacheKey; - if (cacheKey != null && fileDiff.cacheKey == null) { - fileDiff.cacheKey = cacheKey; - } - this.fileDiff = fileDiff; + const { editSessionDiff } = this; + if (editSessionDiff == null) { + throw new Error( + 'FileDiff.applyDocumentChange: requires an active edit session' + ); } + this.detachAdditionLines(); + this.hunksRenderer.beginEditSession(editSessionDiff); + this.hunksRenderer.applyDocumentChange(textDocument); if ( newLineAnnotations !== undefined && newLineAnnotations !== this.lineAnnotations @@ -1476,6 +1762,14 @@ export class FileDiff< lineCountChangeInFlight?: boolean; } = {} ): void { + const { editSessionDiff } = this; + if (editSessionDiff == null) { + throw new Error( + 'FileDiff.updateRenderCache: requires an active edit session' + ); + } + this.detachAdditionLines(); + this.hunksRenderer.beginEditSession(editSessionDiff); const { shouldRefreshDiffsView, lineCountChangeInFlight } = options; const regionsChanged = this.hunksRenderer.updateRenderCache( dirtyLines, @@ -1514,11 +1808,22 @@ export class FileDiff< } } + private detachAdditionLines(): void { + const { editSessionDiff, fileDiff } = this; + if ( + editSessionDiff != null && + (editSessionDiff.additionLines === fileDiff?.additionLines || + editSessionDiff.additionLines === editSessionDiff.deletionLines) + ) { + editSessionDiff.additionLines = [...editSessionDiff.additionLines]; + } + } + // Editor-facing visibility oracle: whether a one-based new-file line has // (or will have on scroll) a rendered row under the current expansion // state. See isAdditionLineRenderable. public isLineRenderable(lineNumber: number): boolean { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getRenderedDiff(); if (fileDiff == null) { return true; } @@ -1542,7 +1847,7 @@ export class FileDiff< lineNumber: number, direction: 'up' | 'down' ): number | undefined { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getRenderedDiff(); if (fileDiff == null) { return lineNumber; } @@ -1567,7 +1872,7 @@ export class FileDiff< // Routed through expandHunk so subclass expansion flows (CodeView's // deferred pendingExpansions) apply. public revealLine(lineNumber: number): boolean { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getRenderedDiff(); const { expandUnchanged = false, collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, @@ -1798,7 +2103,7 @@ export class FileDiff< this.unsafeCSSStyle = undefined; this.appliedUnsafeCSS = undefined; - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; this.lastRowCount = undefined; this.mounted = false; } @@ -1924,7 +2229,7 @@ export class FileDiff< } this.fileContainer = nextContainer; if (previousContainer != null && containerChanged) { - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; this.headerElement = undefined; } if (parentNode != null && this.fileContainer.parentNode !== parentNode) { @@ -2031,15 +2336,31 @@ export class FileDiff< private applyHeaderToDOM( headerAST: HASTElement, - container: HTMLElement + container: HTMLElement, + fileDiff: FileDiffMetadata ): void { this.cleanupErrorWrapper(); this.placeHolder?.remove(); this.placeHolder = undefined; - const { fileDiff } = this; - const headerHTML = this.cachedHeaderHTML ?? toHtml(headerAST); - this.cachedHeaderHTML = headerHTML; - if (headerHTML !== this.lastRenderedHeaderHTML) { + // Session metadata changes in place, so an HTML cache created from the + // external baseline cannot describe the current edit-session header. + const { + headerCache: { + fileDiff: cachedHeaderDiff, + html: cachedHeaderHTML, + lastRenderedHTML, + }, + editSessionDiff, + } = this; + const reusableHeaderHTML = + fileDiff !== editSessionDiff && + areDiffTargetsEqual(cachedHeaderDiff, fileDiff) + ? cachedHeaderHTML + : undefined; + const headerHTML = reusableHeaderHTML ?? toHtml(headerAST); + this.headerCache.html = headerHTML; + this.headerCache.fileDiff = fileDiff; + if (headerHTML !== lastRenderedHTML) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = headerHTML; const newHeader = tempDiv.firstElementChild; @@ -2052,10 +2373,10 @@ export class FileDiff< container.shadowRoot?.prepend(newHeader); } this.headerElement = newHeader; - this.lastRenderedHeaderHTML = headerHTML; + this.headerCache.lastRenderedHTML = headerHTML; } - if (this.isContainerManaged || fileDiff == null) { + if (this.isContainerManaged) { return; } @@ -2108,6 +2429,11 @@ export class FileDiff< this.headerCustom = undefined; } + protected clearReusableHeader(): void { + this.headerCache.html = undefined; + this.headerCache.fileDiff = undefined; + } + private clearHeaderSlots(): void { this.headerPrefix?.remove(); this.headerFilenameSuffix?.remove(); @@ -2437,6 +2763,7 @@ export class FileDiff< } private applyPartialRender({ + fileDiff, previousRenderRange, renderRange, }: ApplyPartialRenderProps): boolean { @@ -2507,10 +2834,10 @@ export class FileDiff< startingLine: number, totalLines: number ): HunksRenderResult | undefined => { - if (totalLines <= 0 || this.fileDiff == null) { + if (totalLines <= 0) { return undefined; } - return this.hunksRenderer.renderDiff(this.fileDiff, { + return this.hunksRenderer.renderDiff(fileDiff, { startingLine, totalLines, bufferBefore: 0, @@ -2551,6 +2878,7 @@ export class FileDiff< ); } rowCount += result.rowCount; + this.renderedDiff = result.fileDiff; }; this.cleanupErrorWrapper(); @@ -2609,12 +2937,13 @@ export class FileDiff< // fast refresh diff view via updating the `data-line-type` after an edit. // only for split view. private refreshSplitDiffView(): void { - if (this.options.diffStyle !== 'split') { + const fileDiff = this.getLatestDiff(); + if (this.options.diffStyle !== 'split' || fileDiff == null) { return; } const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + fileDiff, this.renderRange ); if (hunksResult == null) { @@ -2668,17 +2997,19 @@ export class FileDiff< applyLineType('deletions', columns[0]); applyLineType('additions', columns[1]); + this.renderedDiff = hunksResult.fileDiff; } // full diff view re-rendering // only for unified view. private refreshUnifiedDiffView(): void { - if (this.options.diffStyle !== 'unified') { + const fileDiff = this.getLatestDiff(); + if (this.options.diffStyle !== 'unified' || fileDiff == null) { return; } const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + fileDiff, this.renderRange ); if (hunksResult == null) { @@ -2712,6 +3043,7 @@ export class FileDiff< this.applyRowSpan('unified', columns, hunksResult.rowCount); this.lastRowCount = hunksResult.rowCount; } + this.renderedDiff = hunksResult.fileDiff; }; if (this.shouldGuardRebuildScroll()) { guardWebKitScrollDuringRebuild(this.pre, applyColumns); diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 864c05bcb..0cf1e3a8e 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -11,7 +11,7 @@ import type { ThemeTypes, VirtualFileMetrics, } from '../types'; -import { areFilesEqual } from '../utils/areFilesEqual'; +import { areFileTargetsEqual } from '../utils/areFileTargetsEqual'; import { areObjectsEqual } from '../utils/areObjectsEqual'; import { areOptionsEqual } from '../utils/areOptionsEqual'; import { @@ -47,6 +47,11 @@ interface FileLayoutCache { fileAnnotationHeight: number; } +interface PendingRender { + latestFile: FileContents; + file: FileContents; +} + const LAYOUT_CHECKPOINT_INTERVAL = 5_000; let instanceId = -1; @@ -79,6 +84,7 @@ export class VirtualizedFile< checkpoints: [], fileAnnotationHeight: 0, }; + private pendingRender: PendingRender | undefined; private isVisible: boolean = false; private isSetup: boolean = false; private layoutDirty = true; @@ -208,7 +214,7 @@ export class VirtualizedFile< // Called after render to reconcile estimated vs actual heights. public reconcileHeights(): boolean { let hasHeightChange = false; - if (this.fileContainer == null || this.file == null) { + if (this.fileContainer == null || this.getLayoutFile() == null) { if (this.height !== 0) { hasHeightChange = true; } @@ -311,23 +317,27 @@ export class VirtualizedFile< return this.render({ file: this.file }); }; - // Prepares this item for CodeView layout by binding the latest file, syncing - // its virtualized top, and returning an approximate height. This method is - // called while downstream items are being re-positioned, so later changes - // should keep clean instances on a cached-height fast path. - public prepareCodeViewItem( + // CodeView positions every item before updating the DOM. Recalculate this + // item's layout whenever its content or position changes. + public updateCodeViewLayout( file: FileContents, top: number, reset?: PendingCodeViewLayoutReset, lineAnnotations?: LineAnnotation[] ): number { - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - const targetChanged = - !areFilesEqual(this.file, file) || - this.fileRenderer.hasUnkeyedFileContentsChanged(file); + const targetChanged = !areFileTargetsEqual(this.file, file); + if (targetChanged) { + this.updateExternalFile(file); + } + const { + pendingRenderFile, + layoutFileChanged, + renderedFileChanged, + annotationsChanged, + } = this.updatePendingRender(file, lineAnnotations); let shouldResetLayoutCache = reset?.resetFileLayoutCache === true || - targetChanged || + layoutFileChanged || annotationsChanged; if (reset?.metrics != null) { this.metrics = reset.metrics; @@ -344,24 +354,36 @@ export class VirtualizedFile< this.resetLayoutCache(); } - if (this.file !== file) { + if (targetChanged) { this.layoutDirty = true; } - this.file = file; + if ( + !this.forceRenderOverride && + (targetChanged || renderedFileChanged || annotationsChanged) + ) { + this.forceRenderOverride = true; + } this.top = top; - this.computeApproximateSize(); + this.computeApproximateSize(false, pendingRenderFile); return this.height; } + // CodeView calculates layout before it renders the next item. Keep every + // geometry read in that frame tied to the file selected for that render. + private getLayoutFile(): FileContents | undefined { + return this.pendingRender?.file ?? this.getRenderedFile(); + } + public getLinePosition( lineNumber: number ): { top: number; height: number } | undefined { - if (this.file == null || lineNumber < 1) { + const file = this.getLayoutFile(); + if (file == null || lineNumber < 1) { return undefined; } const { disableFileHeader = false, collapsed = false } = this.options; - const lastLineIndex = this.fileRenderer.getLineCount(this.file) - 1; + const lastLineIndex = this.fileRenderer.getLineCount(file) - 1; let top = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); if (collapsed || lastLineIndex < 0) { @@ -409,7 +431,8 @@ export class VirtualizedFile< public getNumericScrollAnchor( localViewportTop: number ): NumericScrollLineAnchor | undefined { - if (this.file == null || this.renderRange == null) { + const file = this.getLayoutFile(); + if (file == null || this.renderRange == null) { return undefined; } @@ -422,7 +445,7 @@ export class VirtualizedFile< return undefined; } - const lastLineIndex = this.fileRenderer.getLineCount(this.file) - 1; + const lastLineIndex = this.fileRenderer.getLineCount(file) - 1; if (lastLineIndex < 0) { return undefined; } @@ -498,7 +521,8 @@ export class VirtualizedFile< public getAdvancedStickySpecs( windowSpecs?: RenderWindow ): StickySpecs | undefined { - if (this.top == null || this.file == null) { + const file = this.getLayoutFile(); + if (this.top == null || file == null) { return undefined; } if (this.options.collapsed === true) { @@ -506,7 +530,7 @@ export class VirtualizedFile< } const renderRange = windowSpecs != null - ? this.computeRenderRangeFromWindow(this.file, this.top, windowSpecs) + ? this.computeRenderRangeFromWindow(file, this.top, windowSpecs) : this.renderRange; if (renderRange == null) { return undefined; @@ -538,14 +562,23 @@ export class VirtualizedFile< } override cleanUp(recycle = false): void { + const shouldRecomputeLayout = + recycle && + this.isAdvancedMode() && + this.fileContainer != null && + !areFileTargetsEqual(this.getRenderedFile(), this.getLatestFile()); if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } if (!recycle) { this.resetLayoutCache(); } + this.pendingRender = undefined; this.isSetup = false; super.cleanUp(recycle); + if (shouldRecomputeLayout) { + this.virtualizer.instanceChanged(this, true); + } } // Compute the approximate size of the file using cached line heights. @@ -555,7 +588,7 @@ export class VirtualizedFile< // if the height is 100% accurate private computeApproximateSize( force = false, - file: FileContents | undefined = this.file + file: FileContents | undefined = this.getLayoutFile() ): void { const shouldValidateSize = this.isResizeDebuggingEnabled(); if (!force && !this.layoutDirty && !shouldValidateSize) { @@ -642,8 +675,16 @@ export class VirtualizedFile< if (!this.enabled || this.file == null) { return; } + const latestFile = this.getLatestFile(); + const nextRenderFile = + latestFile == null + ? undefined + : this.fileRenderer.getFileForNextRender(latestFile); this.forceRenderOverride = true; - this.virtualizer.instanceChanged(this, false); + this.virtualizer.instanceChanged( + this, + !areFileTargetsEqual(this.getRenderedFile(), nextRenderFile) + ); } // normally triggered by the host when the document line count changes @@ -661,18 +702,19 @@ export class VirtualizedFile< this.getSimpleVirtualizer()?.markDOMDirty(); this.resetLayoutCache(this.isSimpleMode(), false); + const file = this.getRenderedFile(); if (!this.isSimpleMode()) { this.computeApproximateSize(true); } else if ( shouldUpdateBuffer && - previousRenderRange !== undefined && - this.file !== undefined + previousRenderRange != null && + file != null ) { // Update the buffers caused by the line-count change to ensure the host // scrolls to the correct position before re-rendering. const windowSpecs = this.virtualizer.getWindowSpecs(); const renderRange = this.computeRenderRangeFromWindow( - this.file, + file, this.top ?? 0, windowSpecs ); @@ -685,35 +727,50 @@ export class VirtualizedFile< this.virtualizer.instanceChanged(this, true); } - protected override renderPreparedFile({ + override render({ fileContainer, file, forceRender = false, lineAnnotations, ...props }: FileRenderProps): boolean { - const didFileChange = - this.file == null || - !areFilesEqual(this.file, file) || - this.fileRenderer.hasUnkeyedFileContentsChanged(file); + const didFileChange = !areFileTargetsEqual(this.file, file); + if (didFileChange) { + this.updateExternalFile(file); + this.cachedHeaderHTML = undefined; + } + const { + pendingRenderFile, + layoutFileChanged, + renderedFileChanged, + annotationsChanged, + } = (() => { + if ( + this.pendingRender != null && + areFileTargetsEqual( + this.pendingRender.latestFile, + this.getLatestFile(file) ?? file + ) + ) { + return { + pendingRenderFile: this.pendingRender.file, + layoutFileChanged: false, + renderedFileChanged: false, + annotationsChanged: false, + }; + } + return this.updatePendingRender(file, lineAnnotations); + })(); const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - if (annotationsChanged) { + if (annotationsChanged || layoutFileChanged) { this.resetLayoutCache(); } fileContainer = this.getOrCreateFileContainerNode(fileContainer); - if (file == null) { - console.error( - 'VirtualizedFile.render: attempting to virtually render when we dont have file' - ); - return false; - } - if (!isSetup) { - this.computeApproximateSize(false, file); + this.computeApproximateSize(false, pendingRenderFile); const virtualizer = this.getSimpleVirtualizer(); this.top ??= this.getVirtualizedTop(); if (this.isAdvancedMode()) { @@ -733,10 +790,10 @@ export class VirtualizedFile< this.isSetup = true; } else { this.top ??= this.getVirtualizedTop(); - if (didFileChange && this.isSimpleMode()) { + if (layoutFileChanged && this.isSimpleMode()) { this.getSimpleVirtualizer()?.markDOMDirty(); this.resetLayoutCache(false); - this.computeApproximateSize(false, file); + this.computeApproximateSize(false, pendingRenderFile); } } @@ -748,21 +805,18 @@ export class VirtualizedFile< this.isSimpleMode() && (!didFileChange || !isSetup) ) { - this.file = file; - if (didFileChange) { - this.cachedHeaderHTML = undefined; - } + this.pendingRender = undefined; return this.renderPlaceholder(this.height); } const windowSpecs = this.virtualizer.getWindowSpecs(); const fileTop = this.top ?? 0; const renderRange = this.computeRenderRangeFromWindow( - file, + pendingRenderFile, fileTop, windowSpecs ); - const rendered = super.renderPreparedFile({ + const rendered = super.render({ file, fileContainer, renderRange, @@ -770,9 +824,18 @@ export class VirtualizedFile< forceRender: (forceRenderOverride ?? forceRender) || annotationsChanged || + renderedFileChanged || didFileChange, ...props, }); + if (rendered) { + if (this.getRenderedFile() !== pendingRenderFile) { + throw new Error( + 'VirtualizedFile.render: rendered a different file than its prepared layout' + ); + } + this.pendingRender = undefined; + } // Renders can be driven from outside the virtualizer (host/React render // calls, async highlight completions), and the virtualizer only // auto-reconciles renders it initiated. Queue a measured-height @@ -784,6 +847,32 @@ export class VirtualizedFile< return rendered; } + private updatePendingRender( + nextFile: FileContents, + lineAnnotations: LineAnnotation[] | undefined + ) { + const latestFile = this.getLatestFile(nextFile) ?? nextFile; + const previousRenderedFile = this.getRenderedFile(); + const previousLayoutFile = this.pendingRender?.file ?? previousRenderedFile; + const pendingRenderFile = + this.fileRenderer.getFileForNextRender(latestFile); + + this.pendingRender = { latestFile, file: pendingRenderFile }; + + return { + pendingRenderFile, + annotationsChanged: this.syncLineAnnotations(lineAnnotations), + layoutFileChanged: !areFileTargetsEqual( + previousLayoutFile, + pendingRenderFile + ), + renderedFileChanged: !areFileTargetsEqual( + previousRenderedFile, + pendingRenderFile + ), + }; + } + public syncVirtualizedTop(): void { this.top = this.getVirtualizedTop(); } diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 76b20e160..39883bb22 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -104,6 +104,11 @@ interface PendingExpansion { expansionLineCountOverride: number | undefined; } +interface PendingRender { + latestDiff: FileDiffMetadata; + diff: FileDiffMetadata; +} + export const VIRTUALIZED_FILE_DIFF_LAYOUT_CHECKPOINT_INTERVAL = 3_000; let instanceId = -1; @@ -131,9 +136,11 @@ export class VirtualizedFileDiff< private layoutDirty = true; private forceRenderOverride: true | undefined; private currentCollapsed: boolean | undefined; - private currentExpandUnchanged: boolean | undefined; private pendingHydratedDiff: PendingLoadedDiff | undefined; private pendingExpansions: PendingExpansion[] | undefined; + // CodeView calculates the next layout before its DOM pass. Keep that + // selection separate from renderedDiff until render() applies it. + private pendingRender: PendingRender | undefined; constructor( options: FileDiffOptions | undefined, @@ -193,13 +200,12 @@ export class VirtualizedFileDiff< return true; } - private hasFileAnnotations( - fileDiff: FileDiffMetadata | undefined = this.fileDiff - ): boolean { - if (fileDiff == null || !includesFileAnnotations(this.lineAnnotations)) { + private hasFileAnnotations(fileDiff: FileDiffMetadata): boolean { + const { lineAnnotations } = this; + if (!includesFileAnnotations(lineAnnotations)) { return false; } - return this.lineAnnotations.some((annotation) => { + return lineAnnotations.some((annotation) => { if (annotation.lineNumber !== FILE_ANNOTATION_LINE_NUMBER) { return false; } @@ -323,7 +329,8 @@ export class VirtualizedFileDiff< public reconcileHeights(): boolean { let hasHeightChange = false; const { overflow = 'scroll' } = this.options; - if (this.fileContainer == null || this.fileDiff == null) { + const fileDiff = this.getRenderedDiff(); + if (this.fileContainer == null || fileDiff == null) { if (this.height !== 0) { hasHeightChange = true; } @@ -331,13 +338,14 @@ export class VirtualizedFileDiff< return hasHeightChange; } this.top = this.getVirtualizedTop(); + const { lineAnnotations } = this; // NOTE(amadeus): We can probably be a lot smarter about this, and we // should be thinking about ways to improve this // If the file has no annotations and we are using the scroll variant, then // we can probably skip everything if ( overflow === 'scroll' && - this.lineAnnotations.length === 0 && + lineAnnotations.length === 0 && !this.isResizeDebuggingEnabled() ) { return hasHeightChange; @@ -348,7 +356,7 @@ export class VirtualizedFileDiff< ? [this.codeDeletions, this.codeAdditions] : [this.codeUnified]; - const hasFileAnnotations = this.hasFileAnnotations(this.fileDiff); + const hasFileAnnotations = this.hasFileAnnotations(fileDiff); if ( this.renderRange != null && hasFileAnnotations && @@ -432,57 +440,100 @@ export class VirtualizedFileDiff< } } - // Prepares this item for CodeView layout by binding the latest diff, syncing - // its virtualized top, and returning an approximate height. This method is - // called while downstream items are being re-positioned, so later changes - // should keep clean instances on a cached-height fast path. - public prepareCodeViewItem( + // CodeView positions every item before updating the DOM. Recalculate this + // item's layout whenever its content or position changes. + public updateCodeViewLayout( fileDiff: FileDiffMetadata, top: number, reset?: PendingCodeViewLayoutReset, lineAnnotations?: DiffLineAnnotation[] ): number { - const targetChanged = !areDiffTargetsEqual(this.fileDiff, fileDiff); - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - let shouldResetLayoutCache = + let resetLayoutCache = false; + let resetEstimatedHeights = false; + const { + pendingExpansions, + pendingHydratedDiff, + options: { collapsed = false }, + } = this; + + // Hydrate the `isPartial: true` diff if we have a pending hydration + if (pendingHydratedDiff != null) { + this.pendingHydratedDiff = undefined; + if (pendingHydratedDiff.expectedDiff === fileDiff) { + // We intentionally keep diff equality referential, + // and treat it as a mutation + Object.assign(fileDiff, pendingHydratedDiff.nextDiff); + this.setHydratedState(pendingHydratedDiff.files); + this.startHydratedEditSession(fileDiff); + this.forceRenderOverride = true; + resetLayoutCache = true; + resetEstimatedHeights = true; + } + } + + // Go ahead and apply any queued expansion changes + if (pendingExpansions != null) { + this.pendingExpansions = undefined; + for (const { + hunkIndex, + direction, + expansionLineCountOverride, + } of pendingExpansions) { + this.hunksRenderer.expandHunk( + hunkIndex, + direction, + expansionLineCountOverride + ); + this.forceRenderOverride = true; + resetEstimatedHeights = true; + } + } + + const diffChanged = this.updateExternalDiff(fileDiff); + const { + pendingRenderDiff, + layoutDiffChanged, + renderedDiffChanged, + annotationsChanged, + } = this.updatePendingRender(fileDiff, lineAnnotations); + if ( + !this.forceRenderOverride && + (diffChanged || renderedDiffChanged || annotationsChanged) + ) { + this.forceRenderOverride = true; + } + + if ( reset?.resetDiffLayoutCache === true || - targetChanged || - annotationsChanged; - let includeEstimatedHeights = - targetChanged || + layoutDiffChanged || + annotationsChanged + ) { + resetLayoutCache = true; + } + if ( + layoutDiffChanged || (reset?.resetDiffLayoutCache === true && - reset.includeEstimatedDiffHeights); - + reset.includeEstimatedDiffHeights) + ) { + resetEstimatedHeights = true; + } if (reset?.metrics != null) { this.metrics = computeVirtualFileMetrics(reset.metrics); - shouldResetLayoutCache = true; - includeEstimatedHeights = true; + resetLayoutCache = true; + resetEstimatedHeights = true; } - - const { collapsed = false, expandUnchanged = false } = this.options; if (this.currentCollapsed !== collapsed) { this.currentCollapsed = collapsed; - shouldResetLayoutCache = true; + resetLayoutCache = true; } - - // CodeView's options facade forces expandUnchanged on while this item is - // in edit mode, so the effective value can flip without any option or - // target change reaching this instance. The estimated heights bake - // expansion in, so a flip must rebuild the layout caches just like a - // collapsed change — otherwise the item keeps its collapsed-layout height - // while (re)mounts render the expanded rows, overlapping the items below. - if (this.currentExpandUnchanged !== expandUnchanged) { - this.currentExpandUnchanged = expandUnchanged; - shouldResetLayoutCache = true; - includeEstimatedHeights = true; + if (resetLayoutCache) { + this.resetLayoutCache({ includeEstimatedHeights: resetEstimatedHeights }); + } else if (resetEstimatedHeights) { + this.invalidateDerivedLayoutCache(true); } - if (shouldResetLayoutCache) { - this.resetLayoutCache({ includeEstimatedHeights }); - } - this.fileDiff = fileDiff; this.top = top; - this.computeApproximateSize(); + this.computeApproximateSize(false, pendingRenderDiff); return this.height; } @@ -490,11 +541,16 @@ export class VirtualizedFileDiff< lineNumber: number, side: SelectionSide = 'additions' ): { top: number; height: number } | undefined { - if (this.fileDiff == null || lineNumber < 1) { + const fileDiff = this.getLayoutDiff(); + if (fileDiff == null || lineNumber < 1) { return undefined; } - const targetLineIndexes = this.getLineIndex(lineNumber, side); + const targetLineIndexes = this.getLineIndexForDiff( + fileDiff, + lineNumber, + side + ); if (targetLineIndexes == null) { return undefined; } @@ -509,7 +565,7 @@ export class VirtualizedFileDiff< const hunkSeparators = this.getHunkSeparatorType(); const targetLineIndex = diffStyle === 'split' ? targetLineIndexes[1] : targetLineIndexes[0]; - this.approximateLayoutCheckpoints(); + this.approximateLayoutCheckpoints(fileDiff); const headerRegion = getVirtualFileHeaderRegion( this.metrics, disableFileHeader @@ -523,7 +579,7 @@ export class VirtualizedFileDiff< let position: { top: number; height: number } | undefined; iterateOverDiff({ - diff: this.fileDiff, + diff: fileDiff, diffStyle, startingLine: checkpoint?.renderedLineIndex ?? 0, expandedHunks: expandUnchanged @@ -621,7 +677,8 @@ export class VirtualizedFileDiff< public getNumericScrollAnchor( localViewportTop: number ): NumericScrollLineAnchor | undefined { - if (this.fileDiff == null) { + const fileDiff = this.getLayoutDiff(); + if (fileDiff == null) { return undefined; } @@ -638,7 +695,7 @@ export class VirtualizedFileDiff< const diffStyle = this.getDiffStyle(); const hunkSeparators = this.getHunkSeparatorType(); - this.approximateLayoutCheckpoints(); + this.approximateLayoutCheckpoints(fileDiff); const checkpoint = this.getLayoutCheckpointBeforeTop(localViewportTop); let top = checkpoint?.top ?? @@ -650,7 +707,7 @@ export class VirtualizedFileDiff< // need to figure out how to anchor on different regions, or utilize // renderRange to shortcut this for us somehow iterateOverDiff({ - diff: this.fileDiff, + diff: fileDiff, diffStyle, startingLine: checkpoint?.renderedLineIndex ?? 0, expandedHunks: expandUnchanged @@ -737,7 +794,8 @@ export class VirtualizedFileDiff< public getAdvancedStickySpecs( windowSpecs?: RenderWindow ): StickySpecs | undefined { - if (this.top == null || this.fileDiff == null) { + const fileDiff = this.getLayoutDiff(); + if (this.top == null || fileDiff == null) { return undefined; } if (this.options.collapsed === true) { @@ -745,11 +803,7 @@ export class VirtualizedFileDiff< } const renderRange = windowSpecs != null - ? this.computeRenderRangeFromWindow( - this.fileDiff, - this.top, - windowSpecs - ) + ? this.computeRenderRangeFromWindow(fileDiff, this.top, windowSpecs) : this.renderRange; if (renderRange == null) { return undefined; @@ -781,6 +835,11 @@ export class VirtualizedFileDiff< } override cleanUp(recycle = false): void { + const shouldRecomputeLayout = + recycle && + this.isAdvancedMode() && + this.fileContainer != null && + !areDiffTargetsEqual(this.getRenderedDiff(), this.getLatestDiff()); if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } @@ -789,8 +848,12 @@ export class VirtualizedFileDiff< this.pendingExpansions = undefined; this.pendingHydratedDiff = undefined; } + this.pendingRender = undefined; this.isSetup = false; super.cleanUp(recycle); + if (shouldRecomputeLayout) { + this.virtualizer.instanceChanged(this, true); + } } override expandHunk = ( @@ -847,9 +910,11 @@ export class VirtualizedFileDiff< } else { hydratePartialDiff('merge', expectedDiff, files); this.setHydratedState(files); - await awaitWithTimeout(() => this.primeHighlightCache(expectedDiff)); - if (!this.enabled || this.fileDiff !== expectedDiff) { - return; + if (!this.startHydratedEditSession(expectedDiff)) { + await awaitWithTimeout(() => this.primeHighlightCache(expectedDiff)); + if (!this.enabled || this.fileDiff !== expectedDiff) { + return; + } } this.resetLayoutCache({ includeEstimatedHeights: true }); this.computeApproximateSize(); @@ -858,44 +923,6 @@ export class VirtualizedFileDiff< this.virtualizer.instanceChanged(this, true); } - public consumeCodeViewLayoutChanges( - expectedFileDiff: FileDiffMetadata - ): FileDiffMetadata | undefined { - let hasLayoutChange = false; - let nextDiff: FileDiffMetadata | undefined; - const { pendingExpansions, pendingHydratedDiff } = this; - - if (pendingExpansions != null) { - this.pendingExpansions = undefined; - for (const pendingExpansion of pendingExpansions) { - this.hunksRenderer.expandHunk( - pendingExpansion.hunkIndex, - pendingExpansion.direction, - pendingExpansion.expansionLineCountOverride - ); - hasLayoutChange = true; - } - } - - if (pendingHydratedDiff != null) { - this.pendingHydratedDiff = undefined; - if (pendingHydratedDiff.expectedDiff === expectedFileDiff) { - this.setHydratedState(pendingHydratedDiff.files); - nextDiff = pendingHydratedDiff.nextDiff; - } - } - - if (nextDiff != null) { - this.forceRenderOverride = true; - this.resetLayoutCache({ includeEstimatedHeights: true }); - } else if (hasLayoutChange) { - this.forceRenderOverride = true; - this.invalidateDerivedLayoutCache(true); - } - - return nextDiff; - } - protected override loadFilesIfNecessary(): void { if (this.pendingHydratedDiff != null) { if (this.pendingHydratedDiff.expectedDiff === this.fileDiff) { @@ -916,7 +943,7 @@ export class VirtualizedFileDiff< return true; } const { pendingExpansions } = this; - const fileDiff = this.fileDiffCache; + const fileDiff = this.getRenderedDiff(); if ( pendingExpansions == null || pendingExpansions.length === 0 || @@ -1014,8 +1041,16 @@ export class VirtualizedFileDiff< ) { return; } + const latestDiff = this.getLatestDiff(); + const nextRenderDiff = + latestDiff == null + ? undefined + : this.hunksRenderer.getDiffForNextRender(latestDiff); this.forceRenderOverride = true; - this.virtualizer.instanceChanged(this, false); + this.virtualizer.instanceChanged( + this, + !areDiffTargetsEqual(this.getRenderedDiff(), nextRenderDiff) + ); } // Normally triggered by the host when the document line count changes. @@ -1037,18 +1072,19 @@ export class VirtualizedFileDiff< resetRenderRange: false, }); + const fileDiff = this.getRenderedDiff(); if (!this.isSimpleMode()) { this.computeApproximateSize(true); } else if ( shouldUpdateBuffer && - previousRenderRange !== undefined && - this.fileDiff !== undefined + previousRenderRange != null && + fileDiff != null ) { // Update the buffers caused by the line-count change to ensure the host // scrolls to the correct position before re-rendering. const windowSpecs = this.virtualizer.getWindowSpecs(); const renderRange = this.computeRenderRangeFromWindow( - this.fileDiff, + fileDiff, this.top ?? 0, windowSpecs ); @@ -1068,7 +1104,7 @@ export class VirtualizedFileDiff< // if the height is 100% accurate private computeApproximateSize( force = false, - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { const shouldValidateSize = this.isResizeDebuggingEnabled(); if (!force && !this.layoutDirty && !shouldValidateSize) { @@ -1106,8 +1142,14 @@ export class VirtualizedFileDiff< this.layoutDirty = false; } + // CodeView calculates its next layout before updating the DOM. This keeps every + // layout calculation within that frame tied to the same diff. + private getLayoutDiff(): FileDiffMetadata | undefined { + return this.pendingRender?.diff ?? this.getRenderedDiff(); + } + private getActiveEstimatedHeight( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): number { this.ensureEstimatedDiffHeights(fileDiff); const estimatedHeight = @@ -1123,7 +1165,7 @@ export class VirtualizedFileDiff< } private ensureEstimatedDiffHeights( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { if (fileDiff == null) { this.cache.estimatedSplitHeight = undefined; @@ -1160,7 +1202,7 @@ export class VirtualizedFileDiff< } private validateComputedHeight( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { if (this.fileContainer == null || fileDiff == null) { return; @@ -1215,17 +1257,9 @@ export class VirtualizedFileDiff< } const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - if (annotationsChanged) { - this.resetLayoutCache({ includeEstimatedHeights: false }); - } - const diffInputChanged = fileDiff != null && fileDiff !== this.fileDiff; const targetChanged = nextFileDiff != null && !areDiffTargetsEqual(this.fileDiff, nextFileDiff); - const dataChanged = diffInputChanged || filesDidChange; - if (targetChanged) { - this.resetLayoutCache({ includeEstimatedHeights: true }); - } + const dataChanged = targetChanged || filesDidChange; fileContainer = this.getOrCreateFileContainer(fileContainer); @@ -1235,9 +1269,41 @@ export class VirtualizedFileDiff< ); return false; } + if (targetChanged) { + this.updateExternalDiff(nextFileDiff); + } + + const { + pendingRenderDiff, + layoutDiffChanged, + renderedDiffChanged, + annotationsChanged, + } = (() => { + if ( + this.pendingRender != null && + areDiffTargetsEqual( + this.pendingRender.latestDiff, + this.getLatestDiff(nextFileDiff) ?? nextFileDiff + ) + ) { + return { + pendingRenderDiff: this.pendingRender.diff, + layoutDiffChanged: false, + renderedDiffChanged: false, + annotationsChanged: false, + }; + } + return this.updatePendingRender(nextFileDiff, lineAnnotations); + })(); + + if (annotationsChanged || layoutDiffChanged) { + this.resetLayoutCache({ + includeEstimatedHeights: layoutDiffChanged, + }); + } if (!isSetup) { - this.computeApproximateSize(false, nextFileDiff); + this.computeApproximateSize(false, pendingRenderDiff); const virtualizer = this.getSimpleVirtualizer(); this.top ??= this.getVirtualizedTop(); if (this.isAdvancedMode()) { @@ -1257,28 +1323,28 @@ export class VirtualizedFileDiff< this.isSetup = true; } else { this.top ??= this.getVirtualizedTop(); - if (targetChanged) { + if (layoutDiffChanged) { this.getSimpleVirtualizer()?.markDOMDirty(); - this.computeApproximateSize(false, nextFileDiff); + this.computeApproximateSize(false, pendingRenderDiff); } } if (!this.isVisible && this.isSimpleMode() && (!dataChanged || !isSetup)) { - this.fileDiff = nextFileDiff; if (fileInput != null) { this.deletionFile = oldFile; this.additionFile = newFile; } if (targetChanged) { - this.cachedHeaderHTML = undefined; + this.clearReusableHeader(); } + this.pendingRender = undefined; return this.renderPlaceholder(this.height); } const windowSpecs = this.virtualizer.getWindowSpecs(); const fileTop = this.top ?? 0; const renderRange = this.computeRenderRangeFromWindow( - nextFileDiff, + pendingRenderDiff, fileTop, windowSpecs ); @@ -1290,10 +1356,19 @@ export class VirtualizedFileDiff< forceRender: (forceRenderOverride ?? forceRender) || annotationsChanged || + renderedDiffChanged || targetChanged, ...fileInput, ...fileInputProps, }); + if (rendered) { + if (this.getRenderedDiff() !== pendingRenderDiff) { + throw new Error( + 'VirtualizedFileDiff.render: rendered a different diff than its prepared layout' + ); + } + this.pendingRender = undefined; + } // Renders can be driven from outside the virtualizer (host/React render // calls, async highlight completions), and the virtualizer only // auto-reconciles renders it initiated. Queue a measured-height @@ -1305,6 +1380,33 @@ export class VirtualizedFileDiff< return rendered; } + private updatePendingRender( + nextFileDiff: FileDiffMetadata, + lineAnnotations: DiffLineAnnotation[] | undefined + ) { + const latestDiff = this.getLatestDiff(nextFileDiff) ?? nextFileDiff; + const previousRenderedDiff = this.getRenderedDiff(); + const { pendingRender } = this; + const previousLayoutDiff = pendingRender?.diff ?? previousRenderedDiff; + const pendingRenderDiff = + this.hunksRenderer.getDiffForNextRender(latestDiff); + + this.pendingRender = { latestDiff, diff: pendingRenderDiff }; + + return { + pendingRenderDiff, + annotationsChanged: this.syncLineAnnotations(lineAnnotations), + layoutDiffChanged: !areDiffTargetsEqual( + previousLayoutDiff, + pendingRenderDiff + ), + renderedDiffChanged: !areDiffTargetsEqual( + previousRenderedDiff, + pendingRenderDiff + ), + }; + } + public syncVirtualizedTop(): void { this.top = this.getVirtualizedTop(); } @@ -1356,12 +1458,9 @@ export class VirtualizedFileDiff< return getOptionHunkSeparatorType(this.options.hunkSeparators); } - private approximateLayoutCheckpoints( - fileDiff: FileDiffMetadata | undefined = this.fileDiff - ): void { + private approximateLayoutCheckpoints(fileDiff: FileDiffMetadata): void { if ( (!this.layoutDirty && this.cache.checkpoints.length > 0) || - fileDiff == null || fileDiff.hunks.length === 0 || this.options.collapsed === true ) { diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index f94cddadd..5de67cb32 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -69,6 +69,15 @@ export class EditStack { return this.#redoStack.length > 0; } + /** Create an independent copy of this undo and redo timeline. */ + clone(): EditStack { + const clone = new EditStack({ maxEntries: this.#maxEntries }); + clone.#undoStack = this.#undoStack.map(cloneEditStackEntry); + clone.#redoStack = this.#redoStack.map(cloneEditStackEntry); + clone.#canCoalesce = this.#canCoalesce; + return clone; + } + /** Clears both the undo and redo stacks. */ clear(): void { this.#undoStack.length = 0; @@ -155,6 +164,28 @@ export class EditStack { } } +function cloneEditStackEntry( + entry: EditStackEntry +): EditStackEntry { + return { + ...entry, + forwardEdits: entry.forwardEdits.map((edit) => ({ ...edit })), + inverseEdits: entry.inverseEdits.map((edit) => ({ ...edit })), + selectionsBefore: entry.selectionsBefore?.map(cloneSelection), + selectionsAfter: entry.selectionsAfter?.map(cloneSelection), + lineAnnotationsBefore: entry.lineAnnotationsBefore?.slice(), + lineAnnotationsAfter: entry.lineAnnotationsAfter?.slice(), + }; +} + +function cloneSelection(selection: EditorSelection): EditorSelection { + return { + ...selection, + start: { ...selection.start }, + end: { ...selection.end }, + }; +} + export function createEditStackEntry( textDocument: TextDocument, resolvedEdits: ResolvedTextEdit[], diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 57a23ad08..4a29ccc98 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -6,13 +6,12 @@ import type { DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, - DiffsHighlighter, EditableInstance, + EditorChange, EditorChangeEvent, EditorSelection, EditorState, FileContents, - FileDiffMetadata, HighlightedToken, LineAnnotation, Position, @@ -22,6 +21,7 @@ import type { SelectionSide, TextEdit, } from '../types'; +import { computeLineOffsets } from '../utils/computeFileOffsets'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; import { @@ -169,6 +169,27 @@ function requirePersistedCacheKey( return file.cacheKey; } +/** Describe replacing the complete document from its previous text. */ +function createFullDocumentChange( + previousContents: string, + contents: string +): EditorChange { + const lineOffsets = computeLineOffsets(previousContents); + const lastLineOffset = lineOffsets[lineOffsets.length - 1] ?? 0; + return { + start: 0, + end: previousContents.length, + text: contents, + range: { + start: { line: 0, character: 0 }, + end: { + line: lineOffsets.length - 1, + character: previousContents.length - lastLineOffset, + }, + }, + }; +} + function isPromise(value: T | Promise): value is Promise { return ( typeof value === 'object' && @@ -246,7 +267,11 @@ export interface EditorOptions { editor: Editor, fileInstance: DiffsEditableComponent ) => void; - /** Callback when the editor document changes. */ + /** + * Called whenever the editor document changes. Treat this as a document + * notification; do not feed the changes back into the editor or you will + * create loops. + */ onChange?: ( file: FileContents, lineAnnotations: @@ -351,6 +376,11 @@ export class Editor implements DiffsEditor { // state #fileInstance?: DiffsEditableComponent; #fileInfo?: Omit; + // FIXME(amadeus): This should be removed when we remove persistState + #externalCacheKey?: string; + // FIXME(amadeus): We need this support both types. I think in an ideal + // world, this instance becomes specific to the type of editor that it + // is... and we can manage this without `as` ing thing #lineAnnotations?: DiffLineAnnotation[]; #textDocument?: TextDocument; #renderRange?: RenderRange; @@ -446,14 +476,11 @@ export class Editor implements DiffsEditor { ...this.#options, ...options, }; - if ( - nextOptions.persistState === true && - this.#fileInstance?.type === 'file' - ) { - const file = this.#fileInstance.__getCurrentFile?.() ?? this.#fileInfo; - if (file !== undefined) { - requirePersistedCacheKey(file); - } + if (nextOptions.persistState === true && this.#fileInfo != null) { + requirePersistedCacheKey({ + name: this.#fileInfo.name, + cacheKey: this.#externalCacheKey, + }); } this.#options = nextOptions; if (this.#options.persistState !== true) { @@ -477,12 +504,6 @@ export class Editor implements DiffsEditor { fileInstance: EditableInstance ): () => void; edit(fileInstance: DiffsEditableComponent): () => void { - if (this.#options.persistState === true && fileInstance.type === 'file') { - const file = fileInstance.__getCurrentFile?.(); - if (file !== undefined) { - requirePersistedCacheKey(file); - } - } this.#invalidateOnAttach(); this.#fileInstance = fileInstance; this.#initialize(); @@ -767,15 +788,16 @@ export class Editor implements DiffsEditor { this.#tokenizer = undefined; // A full cleanUp (Edit-mode off, surface switch, unmount) drops the parsed - // document and its file identity so the next edit() rebuilds from the - // host's current contents. A recycle cleanUp — a virtualized host - // temporarily unmounting — keeps them, along with the undo history living - // inside the document, so a later edit() against the same - // name/lang/cacheKey resumes the session via __syncRenderView's - // reused-document path. + // document, render-model metadata, and external cache identity so the next + // edit() rebuilds from the host's current contents. A recycle cleanUp — a + // virtualized host temporarily unmounting — keeps them, along with the undo + // history living inside the document, so a later edit() against the same + // name/lang/external cache key resumes through __syncRenderView's reused- + // document path. if (!recycle) { this.#textDocument = undefined; this.#fileInfo = undefined; + this.#externalCacheKey = undefined; } // dispse event listeners @@ -827,32 +849,23 @@ export class Editor implements DiffsEditor { this.#fileInstance = undefined; } - /** @internal Capture outgoing state and substitute cached text before render. */ - __prepareFile(file: FileContents): FileContents { + /** @internal Return cached text only when it belongs to the same document. */ + __getCachedDocumentContents( + file: Pick + ): string | undefined { if (this.#options.persistState !== true) { - return file; + return undefined; } const cacheKey = requirePersistedCacheKey(file); - const fileInfo = this.#fileInfo; - const languageId = file.lang ?? getFiletypeFromFileName(file.name); - if ( - fileInfo !== undefined && - (requirePersistedCacheKey(fileInfo) !== cacheKey || - fileInfo.name !== file.name || - this.#textDocument?.languageId !== languageId) - ) { - this.#stateRestoreGeneration++; - this.#persistCurrentState(); - } const textDocument = this.#getCachedTextDocument(file, cacheKey); if ( - textDocument === undefined || - textDocument.getText() === file.contents + textDocument == null || + textDocument.uri !== new URL(file.name, 'file://').toString() ) { - return file; + return undefined; } - return { ...file, contents: textDocument.getText() }; + return textDocument.getText(); } /** @internal */ @@ -882,13 +895,19 @@ export class Editor implements DiffsEditor { } /** @internal */ - __syncRenderView: DiffsEditor['__syncRenderView'] = ( - highlighter: DiffsHighlighter, - fileContainer: HTMLElement, - fileOrDiff: FileContents | FileDiffMetadata, - lineAnnotations: DiffLineAnnotation[] | undefined, - renderRange: RenderRange | undefined - ) => { + __syncRenderView: DiffsEditor['__syncRenderView'] = ({ + highlighter, + fileContainer, + externalCacheKey, + renderRange, + resetHistory = false, + ...renderView + }) => { + const isDiff = 'fileDiff' in renderView; + const fileOrDiff = isDiff ? renderView.fileDiff : renderView.file; + const lineAnnotations = renderView.lineAnnotations; + const externalDocument = renderView.externalDocument === true; + const restoredDocument = renderView.restoredDocument; const fileInstance = this.#fileInstance; if (fileInstance == null) { return; @@ -941,47 +960,77 @@ export class Editor implements DiffsEditor { } } - // Whether this sync replaces the document with a freshly parsed one (a new - // file, language, or cache key) versus reusing the existing one. A reused - // document matches the DOM the host just rebuilt: renderers persist edit - // sessions into the host's own data (DiffHunksRenderer keeps - // `diff.additionLines` in sync per edit; FileRenderer writes the session - // contents back into the file on recycle), so an unchanged - // name/lang/cacheKey re-attach renders the same text the document holds. + const languageId = + fileOrDiff.lang ?? getFiletypeFromFileName(fileOrDiff.name); + const contents = + 'contents' in fileOrDiff + ? fileOrDiff.contents + : fileOrDiff.additionLines.join(''); + const previousTextDocument = this.#textDocument; + const documentChanges = + externalDocument && + previousTextDocument !== undefined && + previousTextDocument.getText() !== contents + ? [createFullDocumentChange(previousTextDocument.getText(), contents)] + : restoredDocument != null && restoredDocument !== contents + ? [createFullDocumentChange(restoredDocument, contents)] + : undefined; + + // Components classify external replacements before this sync. A cache-key + // transition identifies new external data, but does not by itself reset + // the active document or its history. const shouldRebuildDocument = this.#textDocument === undefined || this.#fileInfo === undefined || this.#fileInfo.name !== fileOrDiff.name || - this.#fileInfo.lang !== fileOrDiff.lang || - this.#fileInfo.cacheKey !== fileOrDiff.cacheKey; + this.#textDocument.languageId !== languageId || + resetHistory || + (!externalDocument && this.#externalCacheKey !== externalCacheKey); const persistedCacheKey = this.#options.persistState === true - ? requirePersistedCacheKey(fileOrDiff) + ? requirePersistedCacheKey({ + name: fileOrDiff.name, + cacheKey: externalCacheKey, + }) : undefined; let persistedStateTarget: | { cacheKey: string; textDocument: TextDocument } | undefined; + if (externalDocument) { + this.#restoreStateOnNextSync = false; + this.#stateRestoreGeneration++; + this.#persistCurrentState(); + if ( + !shouldRebuildDocument && + this.#options.persistState === true && + this.#textDocument !== undefined && + this.#fileInfo !== undefined + ) { + const previousCacheKey = requirePersistedCacheKey({ + name: this.#fileInfo.name, + cacheKey: this.#externalCacheKey, + }); + this.#textDocumentCache.set( + previousCacheKey, + this.#textDocument.clone() + ); + } + } + if (shouldRebuildDocument) { this.#invalidateOnAttach(); - let contents = ''; - if ('contents' in fileOrDiff) { - contents = fileOrDiff.contents; - } else { - contents = fileOrDiff.additionLines.join(''); - } const editStack = new EditStack({ maxEntries: this.#options.historyMaxEntries, }); const { name, lang, cacheKey } = fileOrDiff; - const languageId = lang ?? getFiletypeFromFileName(fileOrDiff.name); const cachedTextDocument = - persistedCacheKey !== undefined + !externalDocument && persistedCacheKey !== undefined ? this.#getCachedTextDocument(fileOrDiff, persistedCacheKey) : undefined; - // A File render substitutes cached text before painting (see - // __prepareFile), so its DOM always matches a reused document. + // File creates its private render model from cached text before syncing, + // so its DOM always matches a reused document. const reusableTextDocument = fileInstance.type === 'file' || cachedTextDocument?.getText() === contents @@ -991,13 +1040,16 @@ export class Editor implements DiffsEditor { reusableTextDocument ?? new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; + this.#externalCacheKey = externalCacheKey; this.#textDocument = textDocument; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); - persistedStateTarget = { - cacheKey: persistedCacheKey, - textDocument, - }; + if (!externalDocument || !isDiff) { + persistedStateTarget = { + cacheKey: persistedCacheKey, + textDocument, + }; + } } this.#tokenizer?.cleanUp(); this.#tokenizer = undefined; @@ -1016,10 +1068,32 @@ export class Editor implements DiffsEditor { fileOrDiff.name ); } + } else if (externalDocument) { + this.#applyExternalDocumentReplacement( + contents, + isDiff + ? renderView.lineAnnotations + : (renderView.lineAnnotations as + | DiffLineAnnotation[] + | undefined) + ); + const { name, lang, cacheKey } = fileOrDiff; + this.#fileInfo = { name, lang, cacheKey }; + this.#externalCacheKey = externalCacheKey; + if (persistedCacheKey !== undefined && this.#textDocument !== undefined) { + this.#textDocumentCache.set(persistedCacheKey, this.#textDocument); + if (!isDiff) { + persistedStateTarget = { + cacheKey: persistedCacheKey, + textDocument: this.#textDocument, + }; + } + } } if ( persistedStateTarget === undefined && + !externalDocument && this.#restoreStateOnNextSync && persistedCacheKey !== undefined && this.#textDocument !== undefined @@ -1034,7 +1108,8 @@ export class Editor implements DiffsEditor { // right after a fresh document build above, or on the first sync after a // recycle cleanUp re-attached a retained document. Tying it to the // document (rather than the rebuild) is what keeps a re-attach with an - // unchanged cacheKey — which skips the rebuild — able to paint edits. + // unchanged external cache key — which skips the rebuild — able to paint + // edits. const textDocument = this.#textDocument; if (this.#tokenizer == null && textDocument != null) { this.#tokenizer = new EditorTokenizer({ @@ -1120,7 +1195,11 @@ export class Editor implements DiffsEditor { this.#fileInstance?.__getEffectiveCodeOptions() ?? {} ); - this.#lineAnnotations = lineAnnotations; + this.#lineAnnotations = isDiff + ? renderView.lineAnnotations + : (renderView.lineAnnotations as + | DiffLineAnnotation[] + | undefined); this.#renderRange = renderRange; // Remember the bounded window the virtualizer just synced so #applyChange // can clamp any edit-time widening against it. Refreshed on every scroll; @@ -1187,11 +1266,79 @@ export class Editor implements DiffsEditor { ); } + if (documentChanges != null) { + this.#emitChange(documentChanges, lineAnnotations); + } + this.#scheduleOnAttach(fileInstance); }; + // The host component has already rendered these external contents. Update + // only the editor document and history so the same replacement is not + // applied twice. __syncRenderView emits the resulting onChange notification + // afterward. + #applyExternalDocumentReplacement( + contents: string, + lineAnnotations: DiffLineAnnotation[] | undefined + ): void { + const textDocument = this.#textDocument; + if (textDocument == null || textDocument.getText() === contents) { + return; + } + + const selections = this.#selections; + const selectionOffsets = selections?.map( + (selection) => + [ + textDocument.offsetAt(selection.start), + textDocument.offsetAt(selection.end), + ] as const + ); + const replacement = { + start: 0, + end: textDocument.getText().length, + text: contents, + }; + const change = textDocument.applyResolvedEdits( + [replacement], + true, + selections, + undefined, + true + ); + if (change == null) { + return; + } + + if (selections != null && selectionOffsets != null) { + const nextSelections = remapSelectionsAfterEdits( + textDocument, + selections, + selectionOffsets, + [replacement] + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + this.#selections = nextSelections; + } + if (this.#lineAnnotations != null || lineAnnotations != null) { + textDocument.setLastUndoLineAnnotations( + this.#lineAnnotations ?? [], + lineAnnotations ?? [] + ); + } + + this.#tokenizer?.cleanUp(); + this.#tokenizer = undefined; + this.#resetCache(); + this.#wrapLineOffsetsCache.clear(); + this.#markerRenderer?.removePopover(); + if (this.#searchPanel !== undefined && this.#matches !== undefined) { + this.#searchPanel.updateMatches({ syncSelection: false }); + } + } + #getCachedTextDocument( - file: FileContents | FileDiffMetadata, + file: Pick, cacheKey: string ): TextDocument | undefined { const textDocument = this.#textDocumentCache.get(cacheKey); @@ -1226,7 +1373,10 @@ export class Editor implements DiffsEditor { return; } - const cacheKey = requirePersistedCacheKey(fileInfo); + const cacheKey = requirePersistedCacheKey({ + name: fileInfo.name, + cacheKey: this.#externalCacheKey, + }); this.#textDocumentCache.set(cacheKey, textDocument); let storage: IStateStorage; @@ -1322,7 +1472,7 @@ export class Editor implements DiffsEditor { this.#selections !== selections || currentView?.scrollLeft !== view?.scrollLeft || this.#fileInfo === undefined || - requirePersistedCacheKey(this.#fileInfo) !== cacheKey + this.#externalCacheKey !== cacheKey ) { return; } @@ -3447,9 +3597,14 @@ export class Editor implements DiffsEditor { this.#resetCache(); } - if (newLineAnnotations !== undefined) { + if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; - renderLineAnnotations(newLineAnnotations, contentEl, gutterEl); + // A structural FileDiff edit rebuilds both columns and their paired + // annotation rows together. Re-inserting those rows independently by + // line number would break their visual alignment in split view. + if (!this.#isDiff || !didLineCountChange) { + renderLineAnnotations(newLineAnnotations, contentEl, gutterEl); + } } if (this.#options.__debug === true) { @@ -5378,17 +5533,6 @@ export class Editor implements DiffsEditor { newLineAnnotations?: DiffLineAnnotation[], options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } ) { - const fileRef = this.getFile(); - const onChange = this.#options.onChange; - if (fileRef !== undefined && onChange !== undefined) { - const lineAnnotations = newLineAnnotations ?? this.#lineAnnotations; - onChange(fileRef, lineAnnotations, { - changes: change.changes, - file: fileRef, - lineAnnotations, - }); - } - // Invalidate layout caches touched by the edit. Clear cached line Y // positions from startLine onward when either: // - the line count changed (inserts/deletes renumber every later line), or @@ -5511,6 +5655,14 @@ export class Editor implements DiffsEditor { } this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer); + // Publish the change only after the host renderer agrees with the new + // document. Consumers may synchronously render the returned annotations, + // which must not observe the previous line structure. + this.#emitChange( + change.changes, + newLineAnnotations ?? this.#lineAnnotations + ); + if ( options?.skipSearchRefresh !== true && this.#searchPanel !== undefined && @@ -5550,6 +5702,21 @@ export class Editor implements DiffsEditor { } } + #emitChange( + changes: EditorChange[], + lineAnnotations: + | LineAnnotation[] + | DiffLineAnnotation[] + | undefined + ): void { + const file = this.getFile(); + const onChange = this.#options.onChange; + if (file === undefined || onChange === undefined) { + return; + } + onChange(file, lineAnnotations, { changes, file, lineAnnotations }); + } + #applyChangeToLineAnnotations( change: TextDocumentChange ): DiffLineAnnotation[] | undefined { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 0738a6253..05f10c649 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -126,6 +126,17 @@ export class TextDocument { return this.#editStack.canRedo; } + /** Create an independent document with the same text and history. */ + clone(): TextDocument { + return new TextDocument( + this.#uri, + this.getText(), + this.#languageId, + this.#version, + this.#editStack.clone() + ); + } + positionAt(offset: number): Position { return this.normalizePosition(this.#pieceTable.positionAt(offset)); } diff --git a/packages/diffs/src/react/CodeView.tsx b/packages/diffs/src/react/CodeView.tsx index 7aee20bdc..66db15c73 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -74,7 +74,10 @@ interface CodeViewBaseProps { /** Render a non-virtualized node at the very end of the scroll content, after * the last item. Always rendered; scrolls with the content. */ renderCodeViewFooter?(): ReactNode; - /** Called with the owning item on every edited-document change. */ + /** + * Called with the owning item on every document change. Do not feed it + * directly back into the controlled item, which can create update loops. + */ onItemEditChange?( item: CodeViewItem, file: FileContents, diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 0f81d345e..e5f78645b 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -110,6 +110,17 @@ interface GetRenderOptionsReturn { forceHighlight: boolean; } +interface PendingHighlightResult extends RenderDiffResult { + diff: FileDiffMetadata; + highlighted: boolean; +} + +interface DiffRenderCache extends RenderedDiffASTCache { + // hydrate() describes DOM that already exists, even when no reusable AST + // was available for that server-rendered content. + hydrated?: boolean; +} + interface PushSeparatorProps { hunkIndex: number; collapsedLines: number | 'unknown'; @@ -203,6 +214,7 @@ export interface SplitInjectedRowPlacement { } export interface HunksRenderResult { + fileDiff: FileDiffMetadata; unifiedGutterAST: ElementContent[] | undefined; unifiedContentAST: ElementContent[] | undefined; deletionsGutterAST: ElementContent[] | undefined; @@ -227,6 +239,9 @@ export class DiffHunksRenderer { readonly __id: string = `diff-hunks-renderer:${++instanceId}`; private highlighter: DiffsHighlighter | undefined; + // The latest diff requested by the component. The render cache may + // intentionally keep displaying an older highlighted diff while this one + // is highlighted in the background. private diff: FileDiffMetadata | undefined; private expandedHunks = new Map(); @@ -235,7 +250,13 @@ export class DiffHunksRenderer { private additionAnnotations: AnnotationLineMap = {}; private computedLang: SupportedLanguages = 'text'; - private renderCache: RenderedDiffASTCache | undefined; + private renderCache: DiffRenderCache | undefined; + // Completed background work waits here until the next render can update its + // DOM and layout together. + private pendingHighlightResult: PendingHighlightResult | undefined; + // Newly highlighted rows from a line-count edit wait here until the old row + // cache has been shifted to match the document's new line indexes. + private pendingStructuralRows: Map | undefined; // Edit-session state: while active, hunk updates go through the frozen // region skeleton (editSessionHunks) instead of the full recompute, and @@ -271,9 +292,6 @@ export class DiffHunksRenderer { this.additionAnnotations = {}; this.deletionAnnotations = {}; this.workerManager?.cleanUpTasks(this); - // Session hunks and the metadata dirty marker survive recycle in the - // shared FileDiffMetadata; the renderer-local state re-seeds on the next - // attach (beginEditSession). this.endEditSession(); } @@ -281,26 +299,90 @@ export class DiffHunksRenderer { * Enter edit-session mode: hunk updates preserve the current region * skeleton instead of recomputing hunks, and rendering happens locally * with the token transformer forced on (worker-pool requests/results are - * suspended for this renderer). An empty additions document gets one row so - * the editor has a line for its caret. Called on every editor attach, - * including a re-attach after recycle. + * suspended for this renderer). When the session was freshly cloned from + * `externalDiff`, compatible highlighted markup is detached from its external + * cache owner so the editor can reuse it without mutating shared data. An + * empty additions document gets one row for the editor's caret. */ - public beginEditSession(): void { + public beginEditSession( + diff?: FileDiffMetadata, + externalDiff?: FileDiffMetadata + ): void { + const { editSessionActive: wasAlreadyActive, renderCache } = this; this.editSessionActive = true; - const diff = this.diffCache; - if (diff != null && !diff.isPartial && diff.additionLines.length === 0) { + if (!wasAlreadyActive) { + this.pendingHighlightResult = undefined; + } + if (diff != null) { + this.diff = diff; + } + + const currentDiff = diff ?? this.diffCache; + if ( + currentDiff != null && + !currentDiff.isPartial && + currentDiff.additionLines.length === 0 + ) { Object.assign( - diff, - recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) + currentDiff, + recomputeEmptyDocumentDiff(currentDiff, this.options.parseDiffOptions) ); - this.markEditSessionPass(diff); + this.markEditSessionPass(currentDiff); + this.clearRenderCache(); + return; + } + + if (diff == null) { + return; + } + if (renderCache == null) { + return; + } + // Edit updates call this again before each write. That cache is already + // private and must retain plain-text session results. + if (wasAlreadyActive && renderCache.diff === diff) { + return; + } + const { options } = this.getRenderOptions(diff); + const cacheBelongsToSession = renderCache.diff === diff; + const cacheBelongsToExternal = + externalDiff != null && + areDiffTargetsEqual(renderCache.diff, externalDiff); + const { result } = renderCache; + if ( + !renderCache.highlighted || + result == null || + !areDiffRenderOptionsEqual(renderCache.options, options) || + (!cacheBelongsToSession && !cacheBelongsToExternal) + ) { this.clearRenderCache(); + return; + } + if (cacheBelongsToSession) { + return; } + + // Edit paths replace addition entries and their containing array, + // but only read the existing HAST nodes and deletion entries. + this.renderCache = { + diff, + options, + highlighted: true, + result: { + ...result, + code: { + ...result.code, + additionLines: [...result.code.additionLines], + }, + }, + renderRange: renderCache.renderRange, + }; } /** Leave edit-session mode. The exit recompute is the host's concern. */ public endEditSession(): void { this.editSessionActive = false; + this.pendingHighlightResult = undefined; } /** @@ -315,26 +397,25 @@ export class DiffHunksRenderer { } /** - * Re-highlights the current diff in the background and swaps the fresh - * result in (with a re-render) once it completes. Needed after an edit - * session's exit recompute: session passes plain-fill shifted lines in the - * cached result, and the recompute mutates the diff in place (same object, - * same cacheKey), so identity/cacheKey checks would otherwise treat the - * stale highlight as current forever. The current result — content-correct, - * mostly highlighted — keeps rendering until the fresh one lands, so no - * interim paint drops highlighting. + * Re-highlights the current diff in the background and stages the fresh + * result for the next render. Needed after an edit session's exit recompute: + * session passes plain-fill shifted lines in the cached result, and the + * recompute mutates the keyless session diff in place, so object identity + * alone would otherwise treat the stale highlight as current forever. The + * current result — content-correct, mostly highlighted — keeps rendering + * until the fresh one is promoted, so no interim paint drops highlighting. */ public refreshHighlightedResult(): Promise { - const { renderCache } = this; + const { diff, renderCache, workerManager } = this; if ( + diff == null || renderCache == null || - isDiffPlainText(renderCache.diff) || - isDiffMassive(renderCache.diff, this.getTokenizeMaxLength()) + !areDiffTargetsEqual(renderCache.diff, diff) || + isDiffPlainText(diff) || + isDiffMassive(diff, this.getTokenizeMaxLength()) ) { return Promise.resolve(); } - const { diff } = renderCache; - const { workerManager } = this; // The pool's diff cache is keyed by cacheKey, so a worker refresh needs // one; a keyless diff uses the local highlighter fallback below instead. if ( @@ -358,31 +439,33 @@ export class DiffHunksRenderer { .catch((error: unknown) => this.onHighlightError(error)); } - // Installs a freshly highlighted result for the same diff, unless the - // renderer moved on while the highlight ran (new diff, options change, or - // a new edit session whose passes the fresh result wouldn't reflect). + // Holds a freshly highlighted result for the next render transaction, unless + // the renderer moved on while the highlight ran (new diff, options change, + // or a new edit session whose passes the fresh result wouldn't reflect). private applyRefreshedResult( diff: FileDiffMetadata, fresh: RenderDiffResult | undefined ): void { + const { diff: currentDiff, renderCache } = this; if ( fresh == null || - this.renderCache == null || - this.renderCache.diff !== diff || + currentDiff == null || + renderCache == null || + !areDiffTargetsEqual(currentDiff, diff) || + !areDiffTargetsEqual(renderCache.diff, diff) || this.editSessionActive ) { return; } - const { options } = this.getRenderOptions(diff); + const { options } = this.getRenderOptions(currentDiff); if (!areDiffRenderOptionsEqual(options, fresh.options)) { return; } - this.renderCache = { - diff, + this.pendingHighlightResult = { + diff: currentDiff, options: fresh.options, highlighted: true, result: fresh.result, - renderRange: undefined, }; this.onRenderUpdate?.(); } @@ -392,17 +475,9 @@ export class DiffHunksRenderer { } public clearRenderCache(): void { - const renderCache = this.renderCache; this.renderCache = undefined; - if ( - renderCache != null && - renderCache.isDirty === true && - renderCache.diff.cacheKey != null - ) { - // The render cache has been updated by the host, let's purge it - // from the worker manager cache. - this.workerManager?.evictDiffFromCache(renderCache.diff.cacheKey); - } + this.pendingHighlightResult = undefined; + this.pendingStructuralRows = undefined; } public setOptions(options: DiffHunksRendererOptions): void { @@ -488,10 +563,12 @@ export class DiffHunksRenderer { themeType: 'dark' | 'light', lineCountChangeInFlight = false ): boolean { - if (this.renderCache == null) { + this.pendingStructuralRows = undefined; + const { renderCache } = this; + if (renderCache == null) { return false; } - const { result, diff } = this.renderCache; + const { result, diff } = renderCache; if (result == null) { return false; } @@ -500,6 +577,11 @@ export class DiffHunksRenderer { } const hastLines = result.code.additionLines; + const pendingStructuralRows = (this.pendingStructuralRows = + lineCountChangeInFlight ? new Map() : undefined); + // Structural rows use post-edit indexes while the current diff and HAST + // still use pre-edit indexes. Hold those rows until applyDocumentChange + // has shifted the old data into its authoritative positions. const changedAdditionLines: number[] = []; const previousAdditionLines = new Map(); for (const [line, tokens] of dirtyLines) { @@ -512,14 +594,14 @@ export class DiffHunksRenderer { // The host text document can expose one extra trailing empty line when // the file ends with a newline. Deferred tokenization must not grow // additionLines from that mismatch or hunk trailing context desyncs. - if (canSyncDiffLine) { + if (pendingStructuralRows == null && canSyncDiffLine) { diff.additionLines[line] = applyLineTextWithNewline(prevLine, lineText); if (prevText !== lineText) { changedAdditionLines.push(line); previousAdditionLines.set(line, prevLine); } } - hastLines[line] = { + const row: HASTElement = { type: 'element', tagName: 'div', properties: { @@ -550,51 +632,46 @@ export class DiffHunksRenderer { }; }), }; + if (pendingStructuralRows != null) { + pendingStructuralRows.set(line, row); + } else { + hastLines[line] = row; + } } let regionsChanged = false; if (changedAdditionLines.length > 0) { - if (this.editSessionActive && !diff.isPartial) { - // On a line-count pass the tokenizer emits shifted-but-unedited lines - // as dirty and the writes above land at stale indexes, so hunk work - // must wait for the authoritative applyDocumentChange in this same - // pass. Otherwise (including deferred background passes, which carry - // genuine changes and are never followed by applyDocumentChange) the - // explicit changed indexes are current. - if (!lineCountChangeInFlight) { - if ( - diff.additionLines.length <= 1 && - diff.additionLines.join('') === '' - ) { - Object.assign( - diff, - recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) - ); - this.markEditSessionPass(diff); - regionsChanged = true; - } else if ( - shouldTopAlignAdditionRecompute(diff, diff.additionLines) - ) { - Object.assign( - diff, - recomputeTopAlignedAdditionDiff( - diff, - diff.additionLines, - this.options.parseDiffOptions - ) - ); - this.markEditSessionPass(diff); - regionsChanged = true; - } else { - const change = applySessionChangedLines( + if (this.editSessionActive) { + if ( + diff.additionLines.length <= 1 && + diff.additionLines.join('') === '' + ) { + Object.assign( + diff, + recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) + ); + this.markEditSessionPass(diff); + regionsChanged = true; + } else if (shouldTopAlignAdditionRecompute(diff, diff.additionLines)) { + Object.assign( + diff, + recomputeTopAlignedAdditionDiff( diff, - changedAdditionLines, - this.options.parseDiffOptions, - previousAdditionLines - ); - this.applyExpansionRemap(change); - regionsChanged = change != null; - } + diff.additionLines, + this.options.parseDiffOptions + ) + ); + this.markEditSessionPass(diff); + regionsChanged = true; + } else { + const change = applySessionChangedLines( + diff, + changedAdditionLines, + this.options.parseDiffOptions, + previousAdditionLines + ); + this.applyExpansionRemap(change); + regionsChanged = change != null; } } else { Object.assign( @@ -609,7 +686,7 @@ export class DiffHunksRenderer { } result.baseThemeType = themeType; - this.renderCache.isDirty = true; + renderCache.isDirty = true; return regionsChanged; } @@ -624,10 +701,12 @@ export class DiffHunksRenderer { // Normally triggered by the host when the document line count changes. public applyDocumentChange(textDocument: DiffsTextDocument): void { - if (this.renderCache == null) { + const { pendingStructuralRows, renderCache } = this; + this.pendingStructuralRows = undefined; + if (renderCache == null) { return; } - const { diff, result } = this.renderCache; + const { diff, result } = renderCache; if (result == null) { return; } @@ -635,11 +714,10 @@ export class DiffHunksRenderer { throw new Error('Could not apply document change for partial diff'); } - // updateRenderCache may already have extended diff.additionLines for the - // same edit pass, so never bail out purely on matching lengths here. - // Read line-by-line from the editor document instead of materializing the - // entire text. This preserves blank documents and the final editable empty - // row after a trailing line break. + // The structural token pass leaves the diff in its pre-edit shape so this + // document remains the single source of truth for shifting its lines. + // Reading line-by-line also preserves blank documents and the final + // editable empty row after a trailing line break. const { additionLines: previousAdditionLines } = diff; diff.additionLines = getEditorDocumentLines( textDocument, @@ -674,7 +752,15 @@ export class DiffHunksRenderer { ); } - this.renderCache.isDirty = true; + if (pendingStructuralRows != null) { + for (const [line, row] of pendingStructuralRows) { + if (line < result.code.additionLines.length) { + result.code.additionLines[line] = row; + } + } + } + + renderCache.isDirty = true; } // Session-mode counterpart of the line-count recompute: derive canonical @@ -819,6 +905,7 @@ export class DiffHunksRenderer { } this.renderCache ??= { diff, + hydrated: true, highlighted: !massiveDiff && !isDiffPlainText(diff), options, result: massiveDiff ? undefined : cache?.result, @@ -902,23 +989,87 @@ export class DiffHunksRenderer { return { options, forceHighlight: false }; } + /** + * Returns the diff that the next synchronous render can commit without + * changing the renderer's current diff or render cache. Components use this + * to prepare state that must match the following DOM render. + */ + public getDiffForNextRender(diff: FileDiffMetadata): FileDiffMetadata { + const { options } = this.getRenderOptions(diff); + if (this.getReadyRenderResult(diff, options) != null) { + return diff; + } + + if (this.renderCache == null) { + return diff; + } + if (areDiffTargetsEqual(this.renderCache.diff, diff)) { + return this.renderCache.diff; + } + + const hasContent = + diff.additionLines.length > 0 || diff.deletionLines.length > 0; + const forcePlainText = + !hasContent || + isDiffPlainText(diff) || + isDiffMassive(diff, this.getTokenizeMaxLength()); + return this.canRenderDiff(diff, options, forcePlainText) + ? diff + : this.renderCache.diff; + } + + private canRenderDiff( + diff: FileDiffMetadata, + options: RenderDiffOptions, + forcePlainText: boolean + ): boolean { + const { renderCache } = this; + if (renderCache == null || areDiffTargetsEqual(renderCache.diff, diff)) { + return true; + } + if (forcePlainText) { + return ( + (renderCache.result == null && renderCache.hydrated !== true) || + this.workerManager?.isWorkingPool() === true || + (this.highlighter != null && areThemesAttached(options.theme)) + ); + } + // Hydration has highlighted DOM without a local AST. It is still active + // rendered content and must remain visible while a non-plain replacement + // is prepared. + if (renderCache.result == null && renderCache.hydrated !== true) { + return true; + } + + if ( + !this.editSessionActive && + this.workerManager?.isWorkingPool() === true + ) { + return !renderCache.highlighted; + } + + return this.highlighter != null && areThemesAttached(options.theme); + } + public renderDiff( - diff: FileDiffMetadata | undefined = this.renderCache?.diff, + diff: FileDiffMetadata | undefined = this.diff, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): HunksRenderResult | undefined { + this.diff = diff; if (diff == null) { + this.pendingHighlightResult = undefined; return undefined; } const { expandUnchanged, collapsedContextThreshold } = this.getOptionsWithDefaults(); let { options, forceHighlight } = this.getRenderOptions(diff); - const cache = this.getMatchingWorkerResultCache(diff, options); - if (cache != null && !this.hasHighlightedRenderCache(diff, options)) { + const readyResult = this.getReadyRenderResult(diff, options); + this.pendingHighlightResult = undefined; + if (readyResult != null) { this.renderCache = { + ...readyResult, diff, - highlighted: true, renderRange: undefined, - ...cache, }; forceHighlight = false; } @@ -935,6 +1086,7 @@ export class DiffHunksRenderer { !hasContent || isDiffPlainText(diff) || isDiffMassive(diff, this.getTokenizeMaxLength()); + const canRenderDiff = this.canRenderDiff(diff, options, forcePlainText); const newContent = !areDiffTargetsEqual(diff, this.renderCache.diff); const newRenderRange = !areRenderRangesEqual( this.renderCache.renderRange, @@ -944,23 +1096,17 @@ export class DiffHunksRenderer { !this.editSessionActive && this.workerManager?.isWorkingPool() === true ) { - // An already-highlighted view is waiting on a fresh highlight for the - // same diff. Returning no result keeps the host's current content in - // place instead of downgrading it to a plain AST; the pending - // highlight's completion will re-render. A different diff or a - // sub-range window still paints plain — the current content cannot - // serve those. - const highlightPending = + // Hydration has highlighted DOM but no local AST. Keep that DOM until + // its corresponding worker result is ready. + const preserveHydratedContent = this.renderCache.result == null && this.renderCache.highlighted && !forcePlainText && !newContent && isDefaultRenderRange(renderRange); - if (highlightPending) { - this.renderCache.highlightPending = true; - } if ( - !highlightPending && + canRenderDiff && + !preserveHydratedContent && (forcePlainText || this.renderCache.result == null || (!this.renderCache.highlighted && (newContent || newRenderRange))) @@ -1012,6 +1158,7 @@ export class DiffHunksRenderer { // the correct language, then we can render plain text and after kick off // an async job to get the highlighted AST if ( + canRenderDiff && this.highlighter != null && hasThemes && (forceHighlight || @@ -1038,11 +1185,6 @@ export class DiffHunksRenderer { // and languages if (!hasThemes || (!forcePlainText && !hasLangs)) { void this.asyncHighlight(diff).then(({ result, options }) => { - // In this case we need to force a re-render, so we can do that by - // reaching into renderCache - if (this.renderCache != null) { - this.renderCache.highlighted = false; - } this.applyHighlightResult(diff, result, options, !forcePlainText); }); } @@ -1060,6 +1202,7 @@ export class DiffHunksRenderer { diff: FileDiffMetadata, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): Promise { + this.diff = diff; const { result } = await this.asyncHighlight(diff); return this.processDiffResult(diff, renderRange, result); } @@ -1169,29 +1312,35 @@ export class DiffHunksRenderer { options: RenderDiffOptions, highlighted = true ): void { - // NOTE(amadeus): This is a bad assumption, and I should figure out - // something better... If renderCache was blown away, we can assume we've - // run cleanUp() - if (this.renderCache == null) { + const { diff: currentDiff, renderCache } = this; + if ( + currentDiff == null || + renderCache == null || + !areDiffTargetsEqual(currentDiff, diff) || + !areDiffRenderOptionsEqual( + options, + this.getRenderOptions(currentDiff).options + ) + ) { return; } - const triggerRenderUpdate = - this.renderCache.highlightPending === true || - !this.renderCache.highlighted || - !areDiffRenderOptionsEqual(this.renderCache.options, options) || - !areDiffTargetsEqual(this.renderCache.diff, diff); + const triggerRender = + renderCache.result == null || + !renderCache.highlighted || + !areDiffRenderOptionsEqual(renderCache.options, options) || + !areDiffTargetsEqual(renderCache.diff, currentDiff); + if (!triggerRender) { + return; + } - this.renderCache = { - diff, + this.pendingHighlightResult = { + diff: currentDiff, options, highlighted, result, - renderRange: undefined, }; - if (triggerRenderUpdate) { - this.onRenderUpdate?.(); - } + this.onRenderUpdate?.(); } private getMatchingWorkerResultCache( @@ -1208,6 +1357,31 @@ export class DiffHunksRenderer { return cache; } + // Returns completed background work that can replace the rendered AST on + // the next render. Reading it does not promote or discard pending work. + private getReadyRenderResult( + diff: FileDiffMetadata, + options: RenderDiffOptions + ): PendingHighlightResult | undefined { + const { pendingHighlightResult } = this; + if ( + pendingHighlightResult != null && + areDiffTargetsEqual(pendingHighlightResult.diff, diff) && + areDiffRenderOptionsEqual(pendingHighlightResult.options, options) + ) { + return pendingHighlightResult; + } + + const workerCache = this.getMatchingWorkerResultCache(diff, options); + // Return nothing when the worker has not finished, or when this diff is + // already rendered with matching highlighted markup. In both cases the + // current render cache should remain unchanged. + if (workerCache == null || this.hasHighlightedRenderCache(diff, options)) { + return undefined; + } + return { diff, highlighted: true, ...workerCache }; + } + private hasHighlightedRenderCache( diff: FileDiffMetadata, options: RenderDiffOptions @@ -1244,7 +1418,6 @@ export class DiffHunksRenderer { } = this.getOptionsWithDefaults(); const isRenderCacheDirty = this.renderCache?.isDirty ?? false; - this.diff = fileDiff; const unified = diffStyle === 'unified'; const canHydrateContext = canHydrateCollapsedContext( fileDiff, @@ -1760,6 +1933,7 @@ export class DiffHunksRenderer { ); return { + fileDiff, unifiedGutterAST: unified && hasContent ? context.unifiedGutterAST.children : undefined, unifiedContentAST, @@ -1778,7 +1952,7 @@ export class DiffHunksRenderer { themeStyles, baseThemeType, headerElement: !disableFileHeader - ? this.renderHeader(this.diff) + ? this.renderHeader(fileDiff) : undefined, totalLines, rowCount: context.rowCount, @@ -2326,8 +2500,8 @@ function contentLineCount(lines: string[]): number { // mid-document must shift the surviving entries to their new indexes — // otherwise rows hidden during the edit (collapsed context) render another // line's stale tokens once they become visible. Entries outside the changed -// window keep their highlighted content; entries inside it become plain-text -// elements that the editor re-tokenizes on its next background pass. +// window keep their highlighted content; changed rows without fresh tokens +// become plain-text elements for the editor's next background pass. // // The bottom-up scan runs over content lines only: a session's first // line-count edit still has `previousLines` in the parsed-diff shape while @@ -2371,11 +2545,6 @@ function realignAdditionHastLines( ) { realigned[nextLines.length - 1] = hastLines[previousLines.length - 1]; } - // Deferred tokenization can write entries past the previous line count; - // those were produced with post-edit indexes and are already in place. - for (let index = previousLines.length; index < nextLines.length; index++) { - realigned[index] ??= hastLines[index]; - } for (let index = prefix; index < nextLines.length; index++) { realigned[index] ??= createPlainAdditionLineElement( index, diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index fed6a00c9..e1b1387e7 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -30,7 +30,7 @@ import type { } from '../types'; import { applyLineTextWithNewline } from '../utils/applyLineTextWithNewline'; import { areFileRenderOptionsEqual } from '../utils/areFileRenderOptionsEqual'; -import { areFilesEqual } from '../utils/areFilesEqual'; +import { areFileTargetsEqual } from '../utils/areFileTargetsEqual'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { linesFromFileContents } from '../utils/computeFileOffsets'; import { createAnnotationElement } from '../utils/createAnnotationElement'; @@ -53,6 +53,7 @@ import { getFileAnnotations, shouldRenderFileAnnotations, } from '../utils/includesFileAnnotations'; +import { isDefaultRenderRange } from '../utils/isDefaultRenderRange'; import { isFilePlainText } from '../utils/isFilePlainText'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import type { WorkerPoolManager } from '../worker'; @@ -67,7 +68,19 @@ interface GetRenderOptionsReturn { forceHighlight: boolean; } +interface PendingHighlightResult extends RenderFileResult { + file: FileContents; + highlighted: boolean; +} + +interface FileRenderCache extends RenderedFileASTCache { + // hydrate() describes DOM that already exists, even when no reusable AST + // was available for that server-rendered content. + hydrated?: boolean; +} + export interface FileRenderResult { + file: FileContents; gutterAST: ElementContent[]; contentAST: ElementContent[]; preAST: HASTElement; @@ -106,7 +119,16 @@ export class FileRenderer { readonly __id: string = `file-renderer:${++instanceId}`; private highlighter: DiffsHighlighter | undefined; - private renderCache: RenderedFileASTCache | undefined; + // The latest file requested by the component. The render cache may + // intentionally keep displaying an older highlighted file while this one + // is highlighted in the background. + private file: FileContents | undefined; + + private renderCache: FileRenderCache | undefined; + // Completed background work waits here until the next render can update its + // DOM and layout together. + private pendingHighlightResult: PendingHighlightResult | undefined; + private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; private lineCache: LineCache | undefined; @@ -120,6 +142,10 @@ export class FileRenderer { // without a session. private editSessionActive = false; + public get fileCache(): FileContents | undefined { + return this.renderCache?.file; + } + constructor( public options: FileRendererOptions = { theme: DEFAULT_THEMES }, private onRenderUpdate?: () => unknown, @@ -163,13 +189,78 @@ export class FileRenderer { * for this renderer. Called on every editor attach, including a re-attach * after recycle. */ - public beginEditSession(): void { + public beginEditSession( + file?: FileContents, + externalFile?: FileContents + ): void { + const { editSessionActive: wasAlreadyActive, renderCache } = this; this.editSessionActive = true; + if (!wasAlreadyActive) { + this.pendingHighlightResult = undefined; + } + if (file == null) { + return; + } + + this.file = file; + if (renderCache == null) { + return; + } + // Edit updates call this again before each write. That cache is already + // private and must retain plain-text session results. + if (wasAlreadyActive && renderCache.file === file) { + return; + } + const { options } = this.getRenderOptions(file); + const cacheBelongsToSession = renderCache.file === file; + const cacheBelongsToExternal = + externalFile != null && + areFileTargetsEqual(renderCache.file, externalFile); + const { result } = renderCache; + if ( + !renderCache.highlighted || + result == null || + !areFileRenderOptionsEqual(renderCache.options, options) || + (!cacheBelongsToSession && !cacheBelongsToExternal) + ) { + this.clearRenderCache(); + this.lineCache = undefined; + this.textDocumentCache = new WeakMap(); + return; + } + if (cacheBelongsToSession) { + return; + } + + this.renderCache = { + ...renderCache, + file, + result: { + ...result, + code: [...result.code], + }, + }; + const { lineCache } = this; + if ( + lineCache != null && + externalFile != null && + isLineCacheForFile(lineCache, externalFile) + ) { + this.lineCache = { + cacheKey: undefined, + file, + sourceContents: file.contents, + lines: lineCache.lines, + }; + } else { + this.lineCache = undefined; + } } /** Leave edit-session mode. Rendering returns to the pool when one works. */ public endEditSession(): void { this.editSessionActive = false; + this.pendingHighlightResult = undefined; } /** @@ -188,6 +279,7 @@ export class FileRenderer { this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); this.lineCache = undefined; + this.file = undefined; // The session flag re-seeds on the next editor attach (beginEditSession). this.endEditSession(); // The edited-document cache is only coherent alongside the render cache @@ -198,62 +290,14 @@ export class FileRenderer { this.textDocumentCache = new WeakMap(); } - // An edit session patches the render caches in place but never rewrites - // `file.contents`, so a recycled host would otherwise rebuild from the - // pre-edit text while the editor resumes its retained (edited) document. - // Diffs don't have this problem because DiffHunksRenderer keeps - // `diff.additionLines` in sync during the session; the file equivalent is - // joining the session-synced line cache back into the file object before - // the caches are dropped. - private syncEditedContentsToFile(): void { - const { renderCache, lineCache } = this; - if ( - renderCache?.isDirty !== true || - lineCache == null || - !isLineCacheForFile(lineCache, renderCache.file) - ) { - return; - } - renderCache.file.contents = lineCache.lines.join(''); - } - - // Unkeyed files use object identity, so compare the retained source text to - // detect in-place mutations that an aliased file object cannot reveal. - public hasUnkeyedFileContentsChanged(file: FileContents): boolean { - const { lineCache } = this; - return ( - file.cacheKey == null && - lineCache != null && - lineCache.file === file && - lineCache.sourceContents !== file.contents - ); - } - - private invalidateChangedUnkeyedFile(file: FileContents): void { - if (!this.hasUnkeyedFileContentsChanged(file)) return; - this.workerManager?.cleanUpTasks(this); - this.clearRenderCache(); - this.lineCache = undefined; - this.textDocumentCache = new WeakMap(); - } - public clearRenderCache(): void { - this.syncEditedContentsToFile(); this.pendingStructuralRows = undefined; - const renderCache = this.renderCache; this.renderCache = undefined; - if ( - renderCache != null && - renderCache.isDirty === true && - renderCache.file.cacheKey != null - ) { - // The render cache has been updated by the host, let's purge it - // from the worker manager cache. - this.workerManager?.evictFileFromCache(renderCache.file.cacheKey); - } + this.pendingHighlightResult = undefined; } public hydrate(file: FileContents): void { + this.file = file; const { options } = this.getRenderOptions(file); const lines = this.getOrCreateLineCache(file); const massiveFile = isFileMassive( @@ -266,6 +310,7 @@ export class FileRenderer { } this.renderCache ??= { file, + hydrated: true, options, highlighted: !massiveFile && !isFilePlainText(file), result: massiveFile ? undefined : cache?.result, @@ -338,7 +383,7 @@ export class FileRenderer { return { options, forceHighlight: true }; } if ( - !areFilesEqual(file, renderCache.file) || + !areFileTargetsEqual(file, renderCache.file) || !areFileRenderOptionsEqual(options, renderCache.options) ) { return { options, forceHighlight: true }; @@ -346,8 +391,70 @@ export class FileRenderer { return { options, forceHighlight: false }; } + /** + * Returns the file that the next synchronous render can commit without + * changing the current render cache. Virtualized layouts use this to stay + * aligned with the DOM while a replacement highlight is still pending. + */ + public getFileForNextRender(file: FileContents): FileContents { + const { options } = this.getRenderOptions(file); + if (this.getReadyRenderResult(file, options) != null) { + return file; + } + + const { renderCache } = this; + if (renderCache == null) { + return file; + } + if (areFileTargetsEqual(renderCache.file, file)) { + return renderCache.file; + } + + const lines = linesFromFileContents(file.contents); + const forcePlainText = + file.contents.length === 0 || + isFilePlainText(file) || + isFileMassive(lines.length, this.getTokenizeMaxLength()); + + return this.canRenderFile(file, options, forcePlainText) + ? file + : renderCache.file; + } + + private canRenderFile( + file: FileContents, + options: RenderFileOptions, + forcePlainText: boolean + ): boolean { + const { renderCache } = this; + if (renderCache == null || areFileTargetsEqual(renderCache.file, file)) { + return true; + } + if (forcePlainText) { + return ( + (renderCache.result == null && renderCache.hydrated !== true) || + this.workerManager?.isWorkingPool() === true || + (this.highlighter != null && areThemesAttached(options.theme)) + ); + } + // Hydration has highlighted DOM without a local AST. It is still active + // rendered content and must remain visible while a non-plain replacement + // is prepared. + if (renderCache.result == null && renderCache.hydrated !== true) { + return true; + } + + if ( + !this.editSessionActive && + this.workerManager?.isWorkingPool() === true + ) { + return !renderCache.highlighted; + } + + return this.highlighter != null && areThemesAttached(options.theme); + } + public getOrCreateLineCache(file: FileContents): string[] { - this.invalidateChangedUnkeyedFile(file); let { lineCache } = this; if (lineCache == null || !isLineCacheForFile(lineCache, file)) { lineCache = { @@ -374,10 +481,11 @@ export class FileRenderer { lineCountChangeInFlight = false ): void { this.pendingStructuralRows = undefined; - if (this.renderCache == null) { + const { renderCache } = this; + if (renderCache == null) { return; } - const { file, result } = this.renderCache; + const { file, result } = renderCache; if (result == null) { return; } @@ -394,7 +502,7 @@ export class FileRenderer { : undefined; for (const [line, tokens] of dirtyLines) { if ( - pendingStructuralRows === undefined && + pendingStructuralRows == null && lineCache != null && line < lineCache.lines.length ) { @@ -435,7 +543,7 @@ export class FileRenderer { }; }), }; - if (pendingStructuralRows !== undefined) { + if (pendingStructuralRows != null) { pendingStructuralRows.set(line, row); } else { result.code[line] = row; @@ -443,17 +551,21 @@ export class FileRenderer { } result.baseThemeType = themeType; - this.renderCache.isDirty = true; + renderCache.isDirty = true; + if (pendingStructuralRows == null && lineCache != null) { + file.contents = lineCache.lines.join(''); + lineCache.sourceContents = file.contents; + } } // normally triggered by the host when the document line count changes public applyDocumentChange(textDocument: DiffsTextDocument): void { - const pendingStructuralRows = this.pendingStructuralRows; + const { pendingStructuralRows, renderCache } = this; this.pendingStructuralRows = undefined; - if (this.renderCache == null) { - return undefined; + if (renderCache == null) { + return; } - const { file, result } = this.renderCache; + const { file, result } = renderCache; // Without a result there is nothing to reconcile the document against, so // do not record it either: the document cache must never claim line // counts the (possibly still highlighting) result cannot back, or the @@ -535,7 +647,7 @@ export class FileRenderer { line.properties['data-line-index'] = i; } } - this.renderCache.isDirty = true; + renderCache.isDirty = true; } // Replace the old split-line cache with the authoritative edited document. this.lineCache = { @@ -545,44 +657,26 @@ export class FileRenderer { lines: nextLines, }; this.textDocumentCache.set(file, textDocument); + file.contents = textDocument.getText(); } public renderFile( - file: FileContents | undefined = this.renderCache?.file, + file: FileContents | undefined = this.file, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): FileRenderResult | undefined { + this.file = file; if (file == null) { + this.pendingHighlightResult = undefined; return undefined; } - this.invalidateChangedUnkeyedFile(file); - if ( - this.renderCache?.isDirty === true && - !areFilesEqual(file, this.renderCache.file) - ) { - this.clearRenderCache(); - this.lineCache = undefined; - this.textDocumentCache = new WeakMap(); - } let { options, forceHighlight } = this.getRenderOptions(file); - // A dirty edit-session cache must not be superseded by a render with - // different options (e.g. a session ending and returning to pool - // options): persist the session text into the file and evict the stale - // pool cache entry first (clearRenderCache does both) so the rebuild - // below uses the edited contents instead of resurrecting pre-edit - // markup. - if ( - this.renderCache?.isDirty === true && - !areFileRenderOptionsEqual(options, this.renderCache.options) - ) { - this.clearRenderCache(); - } - const cache = this.getMatchingWorkerResultCache(file, options); - if (cache != null && !this.hasHighlightedRenderCache(file, options)) { + const readyResult = this.getReadyRenderResult(file, options); + this.pendingHighlightResult = undefined; + if (readyResult != null) { this.renderCache = { + ...readyResult, file, - highlighted: true, renderRange: undefined, - ...cache, }; forceHighlight = false; } @@ -599,7 +693,8 @@ export class FileRenderer { !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); - const newContent = !areFilesEqual(file, this.renderCache.file); + const canRenderFile = this.canRenderFile(file, options, forcePlainText); + const newContent = !areFileTargetsEqual(file, this.renderCache.file); const newRenderRange = !areRenderRangesEqual( this.renderCache.renderRange, renderRange @@ -608,11 +703,20 @@ export class FileRenderer { !this.editSessionActive && this.workerManager?.isWorkingPool() === true ) { - // Cache invalidation based on renderRange comparison + // Hydration has highlighted DOM but no local AST. Keep that DOM until + // its corresponding worker result is ready. + const preserveHydratedContent = + this.renderCache.result == null && + this.renderCache.highlighted && + !forcePlainText && + !newContent && + isDefaultRenderRange(renderRange); if ( - forcePlainText || - this.renderCache.result == null || - (!this.renderCache.highlighted && (newContent || newRenderRange)) + canRenderFile && + !preserveHydratedContent && + (forcePlainText || + this.renderCache.result == null || + (!this.renderCache.highlighted && (newContent || newRenderRange))) ) { this.renderCache.file = file; this.renderCache.options = options; @@ -653,6 +757,7 @@ export class FileRenderer { // the correct language, then we can render plain text and after kick off // an async job to get the highlighted AST if ( + canRenderFile && this.highlighter != null && hasThemes && (forceHighlight || @@ -679,11 +784,6 @@ export class FileRenderer { // and languages if (!hasThemes || (!forcePlainText && !hasLangs)) { void this.asyncHighlight(file).then(({ result, options }) => { - // In this case we need to force a re-render, so we can do that by - // reaching into renderCache - if (this.renderCache != null) { - this.renderCache.highlighted = false; - } this.applyHighlightResult(file, result, options, !forcePlainText); }); } @@ -702,6 +802,7 @@ export class FileRenderer { file: FileContents, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): Promise { + this.file = file; const { result } = await this.asyncHighlight(file); return this.processFileResult(file, renderRange, result); } @@ -825,6 +926,7 @@ export class FileRenderer { // Finalize: wrap gutter and content gutter.properties.style = `grid-row: span ${rowCount}`; return { + file, gutterAST: gutter.children ?? [], contentAST: contentArray, preAST: this.createPreElement(totalLines), @@ -923,25 +1025,35 @@ export class FileRenderer { options: RenderFileOptions, highlighted = true ): void { - if (this.renderCache == null) { + const { file: currentFile, renderCache } = this; + if ( + currentFile == null || + renderCache == null || + !areFileTargetsEqual(file, currentFile) || + !areFileRenderOptionsEqual( + options, + this.getRenderOptions(currentFile).options + ) + ) { return; } - const triggerRenderUpdate = - !areFilesEqual(file, this.renderCache.file) || - !this.renderCache.highlighted || - !areFileRenderOptionsEqual(options, this.renderCache.options); - this.renderCache = { - file, + const triggerRender = + renderCache.result == null || + !renderCache.highlighted || + !areFileRenderOptionsEqual(renderCache.options, options) || + !areFileTargetsEqual(renderCache.file, currentFile); + if (!triggerRender) { + return; + } + + this.pendingHighlightResult = { + file: currentFile, options, highlighted, result, - renderRange: undefined, }; - - if (triggerRenderUpdate) { - this.onRenderUpdate?.(); - } + this.onRenderUpdate?.(); } private getMatchingWorkerResultCache( @@ -958,6 +1070,28 @@ export class FileRenderer { return cache; } + // Returns completed background work that can replace the rendered AST on + // the next render. Reading it does not promote or discard pending work. + private getReadyRenderResult( + file: FileContents, + options: RenderFileOptions + ): PendingHighlightResult | undefined { + const { pendingHighlightResult } = this; + if ( + pendingHighlightResult != null && + areFileTargetsEqual(pendingHighlightResult.file, file) && + areFileRenderOptionsEqual(pendingHighlightResult.options, options) + ) { + return pendingHighlightResult; + } + + const workerCache = this.getMatchingWorkerResultCache(file, options); + if (workerCache == null || this.hasHighlightedRenderCache(file, options)) { + return undefined; + } + return { file, highlighted: true, ...workerCache }; + } + private hasHighlightedRenderCache( file: FileContents, options: RenderFileOptions @@ -966,7 +1100,7 @@ export class FileRenderer { return ( renderCache?.result != null && renderCache.highlighted && - areFilesEqual(file, renderCache.file) && + areFileTargetsEqual(file, renderCache.file) && areFileRenderOptionsEqual(options, renderCache.options) ); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 67c013c7f..287fd87cc 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -835,9 +835,6 @@ export interface RenderedDiffASTCache { result: ThemedDiffResult | undefined; renderRange: RenderRange | undefined; isDirty?: boolean; - // A render was skipped while a highlight was in progress; its completion - // will trigger a re-render. - highlightPending?: boolean; } /** @@ -1026,8 +1023,6 @@ export interface DiffsBaseComponent { export interface DiffsEditableComponent< LAnnotation, > extends DiffsBaseComponent { - /** @internal Return the current file when this component renders one. */ - __getCurrentFile?: () => FileContents | undefined; /** * @internal Code options with worker-pool overrides applied: the theme the * shared highlighter is actually loaded with and the pool's tokenize limit. @@ -1121,22 +1116,53 @@ export type EditableInstance = T extends { ? never : T; +interface SyncRenderViewBaseProps { + highlighter: DiffsHighlighter; + fileContainer: HTMLElement; + externalCacheKey: string | undefined; + renderRange: RenderRange | undefined; + /** Start fresh history instead of retaining or extending the current history. */ + resetHistory?: boolean; +} + +export interface SyncFileRenderViewProps< + LAnnotation, +> extends SyncRenderViewBaseProps { + file: FileContents; + lineAnnotations: LineAnnotation[] | undefined; + /** Treat the supplied contents as an externally provided document update. */ + externalDocument?: boolean; + /** The external contents replaced by a restored persisted document. */ + restoredDocument?: string; +} + +export interface SyncDiffRenderViewProps< + LAnnotation, +> extends SyncRenderViewBaseProps { + fileDiff: FileDiffMetadata; + lineAnnotations: DiffLineAnnotation[] | undefined; + /** Treat the supplied contents as an externally provided document update. */ + externalDocument?: boolean; + /** + * The private diff was initialized from a persisted document. The previous + * external contents are used only to report that restored document change. + */ + restoredDocument?: string; +} + +export type SyncRenderViewProps = + | SyncFileRenderViewProps + | SyncDiffRenderViewProps; + export interface DiffsEditor { - /** @internal */ - __prepareFile?(file: FileContents): FileContents; + /** @internal Return cached text for the same persisted document identity. */ + __getCachedDocumentContents?( + file: Pick + ): string | undefined; __postponeBgTokenizeToNextFrame(): void; /** @internal Capture focus intent before replacing the editable view. */ __captureFocusForDOMReplacement(): void; - __syncRenderView( - highlighter: DiffsHighlighter, - fileContainer: HTMLElement, - fileOrDiff: FileContents | FileDiffMetadata, - lineAnnotations: - | LineAnnotation[] - | DiffLineAnnotation[] - | undefined, - renderRange: RenderRange | undefined - ): void; + __syncRenderView(props: SyncRenderViewProps): void; edit>( fileInstance: EditableInstance ): () => void; diff --git a/packages/diffs/src/utils/areDiffTargetsEqual.ts b/packages/diffs/src/utils/areDiffTargetsEqual.ts index 4a9900140..363b7a963 100644 --- a/packages/diffs/src/utils/areDiffTargetsEqual.ts +++ b/packages/diffs/src/utils/areDiffTargetsEqual.ts @@ -7,8 +7,7 @@ export function areDiffTargetsEqual( diffA: FileDiffMetadata | undefined, diffB: FileDiffMetadata | undefined ): boolean { - return ( - diffA === diffB || - (diffA?.cacheKey != null && diffA.cacheKey === diffB?.cacheKey) - ); + return diffA?.cacheKey != null || diffB?.cacheKey != null + ? diffA?.cacheKey === diffB?.cacheKey + : diffA === diffB; } diff --git a/packages/diffs/src/utils/areFileTargetsEqual.ts b/packages/diffs/src/utils/areFileTargetsEqual.ts new file mode 100644 index 000000000..070228fc3 --- /dev/null +++ b/packages/diffs/src/utils/areFileTargetsEqual.ts @@ -0,0 +1,14 @@ +import type { FileContents } from '../types'; + +// Explicit keys identify a complete external file version. Without a key, the +// file's contents and document identity determine whether it is a new input. +export function areFileTargetsEqual( + fileA: FileContents | undefined, + fileB: FileContents | undefined +): boolean { + return fileA?.cacheKey != null || fileB?.cacheKey != null + ? fileA?.cacheKey === fileB?.cacheKey + : fileA?.contents === fileB?.contents && + fileA?.name === fileB?.name && + fileA?.lang === fileB?.lang; +} diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts index 8fd827179..d6bb7f937 100644 --- a/packages/diffs/src/utils/editSessionHunks.ts +++ b/packages/diffs/src/utils/editSessionHunks.ts @@ -419,7 +419,7 @@ export function finishEditSessionForDiff( if (diff.editSessionDirty !== true) { return false; } - diff.editSessionDirty = undefined; + delete diff.editSessionDirty; // The empty editor row only hosts a caret; it is not file content after exit. Object.assign( diff, diff --git a/packages/diffs/test/CodeView.collapsed.test.ts b/packages/diffs/test/CodeView.collapsed.test.ts index 9a95b5bf6..1389c38cf 100644 --- a/packages/diffs/test/CodeView.collapsed.test.ts +++ b/packages/diffs/test/CodeView.collapsed.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test'; import { CodeView } from '../src/components/CodeView'; -import type { CodeViewItem } from '../src/types'; +import type { + CodeViewDiffItem, + CodeViewItem, + FileContents, + FileDiffMetadata, +} from '../src/types'; import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; import { createRoot, @@ -15,8 +20,8 @@ import { function makeDiffItem( id: string, collapsed?: boolean -): CodeViewItem { - const item: CodeViewItem = { +): CodeViewDiffItem { + const item: CodeViewDiffItem = { id, type: 'diff', fileDiff: parseDiffFromFile( @@ -40,17 +45,26 @@ function hasRenderedCode(item: { element: HTMLElement }): boolean { return item.element.shadowRoot?.querySelector('pre') != null; } +function getRenderedFile(instance: object): FileContents | undefined { + return Reflect.get(instance, 'renderedFile') as FileContents | undefined; +} + +function getRenderedDiff(instance: object): FileDiffMetadata | undefined { + return Reflect.get(instance, 'renderedDiff') as FileDiffMetadata | undefined; +} + describe('CodeView item collapsed state', () => { test('mounts mixed initially collapsed and expanded items', async () => { const { cleanup } = installDom(); const viewer = new CodeView(); + const collapsedFileContents = makeFile('collapsed.txt'); try { viewer.setup(createRoot()); await renderItems(viewer, [ { id: 'file:collapsed.txt', type: 'file', - file: makeFile('collapsed.txt'), + file: collapsedFileContents, collapsed: true, }, makeDiffItem('diff:expanded.txt'), @@ -68,6 +82,28 @@ describe('CodeView item collapsed state', () => { expect(expandedDiff).toBeDefined(); expect(hasRenderedCode(collapsedFile!)).toBe(false); expect(hasRenderedCode(expandedDiff!)).toBe(true); + expect(getRenderedFile(collapsedFile!.instance)).toBe( + collapsedFileContents + ); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('mounts an initially collapsed diff', async () => { + const { cleanup } = installDom(); + const viewer = new CodeView(); + const item = makeDiffItem('diff:collapsed.txt', true); + try { + viewer.setup(createRoot()); + await renderItems(viewer, [item]); + + const renderedItem = viewer.getRenderedItems()[0]; + expect(renderedItem).toBeDefined(); + expect(hasRenderedCode(renderedItem)).toBe(false); + expect(getRenderedDiff(renderedItem.instance)).toBe(item.fileDiff); } finally { viewer.cleanUp(); await wait(0); @@ -98,6 +134,7 @@ describe('CodeView item collapsed state', () => { const collapsedItem = viewer.getRenderedItems()[0]; expect(collapsedItem).toBeDefined(); expect(hasRenderedCode(collapsedItem)).toBe(false); + expect(getRenderedFile(collapsedItem.instance)).toBe(item.file); expect(collapsedItem.instance.getVirtualizedHeight()).toBeLessThan( expandedHeight ); diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index 6ec7a535e..9957552ae 100644 --- a/packages/diffs/test/CodeView.edit.test.ts +++ b/packages/diffs/test/CodeView.edit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { createTwoFilesPatch } from 'diff'; import { CodeView, @@ -13,10 +14,13 @@ import type { DiffsEditableComponent, DiffsEditor, FileContents, + FileDiffLoadedFiles, + FileDiffMetadata, HighlightedToken, LineAnnotation, } from '../src/types'; import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { parsePatchFiles } from '../src/utils/parsePatchFiles'; import { createRoot, dispatchScroll, @@ -99,6 +103,22 @@ function createEditorHarness({ return { editors, createEditor }; } +function getEditSessionDiff(instance: unknown): FileDiffMetadata | undefined { + return (instance as { editSessionDiff?: FileDiffMetadata }).editSessionDiff; +} + +function getEditSessionFile(instance: unknown): FileContents | undefined { + return (instance as { editSessionFile?: FileContents }).editSessionFile; +} + +function getRendererDiff(instance: unknown): FileDiffMetadata | undefined { + return ( + instance as { + hunksRenderer?: { diffCache?: FileDiffMetadata }; + } + ).hunksRenderer?.diffCache; +} + function makeEditFileItem( id: string, edit = true, @@ -684,12 +704,9 @@ describe('CodeView item edit mode', () => { getText: () => documentText, }); - // Scroll the edited item out (recycle) and back in. The recycle - // persists the session document into the item's file (diff parity via - // FileRenderer.syncEditedContentsToFile), so the remount renders the - // grown 40-line contents instead of the pre-edit 30 lines — and no - // longer throws "FileRenderer.processFileResult: Line doesnt exist" - // from a retained document line count disagreeing with the render. + // Scroll the edited item out (recycle) and back in. The private session + // survives the renderer cache reset, so the remount renders its grown + // document without changing the caller-owned item. root.scrollTop = 20_000; dispatchScroll(root); viewer.render(true); @@ -702,6 +719,9 @@ describe('CodeView item edit mode', () => { const remounted = viewer.getRenderedItems()[0]; expect(remounted.id).toBe('edited'); + if (remounted.type !== 'file') { + throw new Error('Expected the edited file to remount'); + } // Render errors are caught and rendered as an error wrapper instead of // propagating, so assert on the rendered result: no error panel, and // the session's 40 lines rendered. @@ -710,7 +730,10 @@ describe('CodeView item edit mode', () => { expect(shadowRoot?.querySelectorAll('[data-line]').length).toBe( lineCount ); - expect(items[0].type === 'file' && items[0].file.contents).toBe( + expect(getEditSessionFile(remounted.instance)?.contents).toBe( + documentText + ); + expect(items[0].type === 'file' && items[0].file.contents).not.toBe( documentText ); } finally { @@ -829,9 +852,8 @@ describe('CodeView item edit mode', () => { const tokens: HighlightedToken[] = [[0, '', 'edited marker line']]; edited.instance.updateRenderCache(new Map([[0, tokens]]), 'light'); - // Scroll the edited item out (recycle) and back in. The recycle joins - // the session-synced line cache back into the item's file, so the - // remount paints the edited text instead of the pre-edit contents. + // Scroll the edited item out (recycle) and back in. The private session + // remains authoritative after the renderer cache is discarded. root.scrollTop = 20_000; dispatchScroll(root); viewer.render(true); @@ -844,14 +866,21 @@ describe('CodeView item edit mode', () => { const remounted = viewer.getRenderedItems()[0]; expect(remounted.id).toBe('edited'); + if (remounted.type !== 'file') { + throw new Error('Expected the edited file to remount'); + } const shadowRoot = remounted.element.shadowRoot; expect(shadowRoot?.querySelector('[data-error-wrapper]')).toBeNull(); expect(shadowRoot?.textContent).toContain('edited marker line'); - // The remaining lines are untouched and the item's file object now - // carries the session text. + // The remaining lines are untouched and the caller-owned item is not. const file = items[0].type === 'file' ? items[0].file : undefined; - expect(file?.contents.startsWith('edited marker line\n')).toBe(true); - expect(file?.contents).toContain('line 2'); + expect(getEditSessionFile(remounted.instance)?.contents).toStartWith( + 'edited marker line\n' + ); + expect(getEditSessionFile(remounted.instance)?.contents).toContain( + 'line 2' + ); + expect(file?.contents.startsWith('edited marker line\n')).toBe(false); } finally { viewer.cleanUp(); await wait(0); @@ -1022,6 +1051,360 @@ describe('CodeView item edit mode', () => { } }); + test('an edited diff emits a change when it accepts a compatible item update', async () => { + const { cleanup } = installDom(); + const editors: Editor[] = []; + const changes: string[] = []; + const initial = makeEditDiffItem('active'); + if (initial.type !== 'diff') { + throw new Error('Expected a diff item.'); + } + initial.fileDiff.cacheKey = 'active:v1'; + const viewer = new CodeView({ + createEditor(options) { + const editor = new Editor(options); + editors.push(editor); + return editor; + }, + onItemEditChange(_item, file) { + changes.push(file.contents); + }, + }); + + try { + viewer.setup(createRoot()); + await renderItems(viewer, [initial]); + await waitFor( + () => editors[0]?.getText() === 'one\ntwo changed\nthree\n' + ); + const editor = editors[0]; + + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 'two changed'.length }, + }, + newText: 'local value', + }, + ]); + expect(changes).toEqual(['one\nlocal value\nthree\n']); + + const replacement: CodeViewItem = { + ...initial, + fileDiff: parseDiffFromFile( + { name: 'active.txt', contents: 'one\ntwo\nthree\n' }, + { name: 'active.txt', contents: 'one\nexternal value\nthree\n' } + ), + version: 1, + }; + replacement.fileDiff.cacheKey = 'active:v2'; + await applyItemUpdate(viewer, replacement); + await waitFor(() => editor.getText() === 'one\nexternal value\nthree\n', { + timeout: 4_000, + }); + + expect(viewer.getEditor('active')).toBe(editor); + expect(editors).toHaveLength(1); + expect(changes).toEqual([ + 'one\nlocal value\nthree\n', + 'one\nexternal value\nthree\n', + ]); + expect( + viewer.getRenderedItems()[0]?.element.shadowRoot?.textContent + ).toContain('external value'); + + editor.undo(); + expect(editor.getText()).toBe('one\nlocal value\nthree\n'); + expect(changes).toEqual([ + 'one\nlocal value\nthree\n', + 'one\nexternal value\nthree\n', + 'one\nlocal value\nthree\n', + ]); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('an edited diff accepts an external update received while recycled', async () => { + const { cleanup } = installDom(); + const editors: Editor[] = []; + const changes: string[] = []; + const initial = makeEditDiffItem('active'); + if (initial.type !== 'diff') { + throw new Error('Expected a diff item.'); + } + initial.fileDiff.cacheKey = 'active:v1'; + const items = [ + initial, + ...Array.from({ length: 39 }, (_, index) => + makeEditFileItem(`file-${index}`, false, 30) + ), + ]; + const viewer = new CodeView({ + createEditor(options) { + const editor = new Editor(options); + editors.push(editor); + return editor; + }, + onItemEditChange(_item, file) { + changes.push(file.contents); + }, + }); + + try { + const root = createRoot(); + viewer.setup(root); + await renderItems(viewer, items); + await waitFor( + () => editors[0]?.getText() === 'one\ntwo changed\nthree\n' + ); + const editor = editors[0]; + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 'two changed'.length }, + }, + newText: 'local value', + }, + ]); + + const rendered = viewer.getRenderedItems()[0]; + const previousSession = getEditSessionDiff(rendered.instance); + root.scrollTop = 30_000; + dispatchScroll(root); + viewer.render(true); + await wait(0); + expect( + viewer.getRenderedItems().some((item) => item.id === initial.id) + ).toBe(false); + + const replacement: CodeViewItem = { + ...initial, + fileDiff: parseDiffFromFile( + { name: 'active.txt', contents: 'one\ntwo\nthree\n' }, + { name: 'active.txt', contents: 'one\nexternal value\nthree\n' } + ), + version: 1, + }; + replacement.fileDiff.cacheKey = 'active:v2'; + await applyItemUpdate(viewer, replacement); + + const replacementSession = getEditSessionDiff(rendered.instance); + expect(replacementSession).not.toBe(previousSession); + expect(replacementSession?.additionLines.join('')).toBe( + 'one\nexternal value\nthree\n' + ); + expect(changes).toEqual(['one\nlocal value\nthree\n']); + + root.scrollTop = 0; + dispatchScroll(root); + viewer.render(true); + await waitFor(() => editor.getText() === 'one\nexternal value\nthree\n', { + timeout: 4_000, + }); + + expect(viewer.getEditor('active')).toBe(editor); + expect(editors).toHaveLength(1); + expect(changes).toEqual([ + 'one\nlocal value\nthree\n', + 'one\nexternal value\nthree\n', + ]); + editor.undo(); + expect(editor.getText()).toBe('one\nlocal value\nthree\n'); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('an edited file accepts an external update received while recycled', async () => { + const { cleanup } = installDom(); + const editors: Editor[] = []; + const changes: string[] = []; + const initial = makeEditFileItem('active'); + if (initial.type !== 'file') { + throw new Error('Expected a file item.'); + } + initial.file.cacheKey = 'active:v1'; + const items = [ + initial, + ...Array.from({ length: 39 }, (_, index) => + makeEditFileItem(`file-${index}`, false, 30) + ), + ]; + const viewer = new CodeView({ + createEditor(options) { + const editor = new Editor(options); + editors.push(editor); + return editor; + }, + onItemEditChange(_item, file) { + changes.push(file.contents); + }, + }); + const localContents = 'local value\n'; + const externalContents = 'external value\n'; + + try { + const root = createRoot(); + viewer.setup(root); + await renderItems(viewer, items); + await waitFor(() => editors[0]?.getText() === initial.file.contents); + const editor = editors[0]; + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { + line: Number.MAX_SAFE_INTEGER, + character: Number.MAX_SAFE_INTEGER, + }, + }, + newText: localContents, + }, + ]); + + const rendered = viewer.getRenderedItems()[0]; + const previousSession = getEditSessionFile(rendered.instance); + root.scrollTop = 30_000; + dispatchScroll(root); + viewer.render(true); + await wait(0); + expect( + viewer.getRenderedItems().some((item) => item.id === initial.id) + ).toBe(false); + + const replacement: CodeViewItem = { + ...initial, + file: { + ...initial.file, + contents: externalContents, + cacheKey: 'active:v2', + }, + version: 1, + }; + await applyItemUpdate(viewer, replacement); + + const replacementSession = getEditSessionFile(rendered.instance); + expect(replacementSession).not.toBe(previousSession); + expect(replacementSession?.contents).toBe(externalContents); + expect(changes).toEqual([localContents]); + + root.scrollTop = 0; + dispatchScroll(root); + viewer.render(true); + await waitFor(() => editor.getText() === externalContents, { + timeout: 4_000, + }); + + expect(viewer.getEditor('active')).toBe(editor); + expect(editors).toHaveLength(1); + expect(changes).toEqual([localContents, externalContents]); + editor.undo(); + expect(editor.getText()).toBe(localContents); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + + test('an edited diff hydrates an external update received while recycled', async () => { + const { cleanup } = installDom(); + const editors: Editor[] = []; + const loadedFiles: FileDiffLoadedFiles = { + oldFile: { name: 'active.txt', contents: 'one\ntwo\nthree\n' }, + newFile: { + name: 'active.txt', + contents: 'one\nexternal value\nthree\n', + }, + }; + const initial = makeEditDiffItem('active'); + if (initial.type !== 'diff') { + throw new Error('Expected a diff item.'); + } + initial.fileDiff.cacheKey = 'active:v1'; + const viewer = new CodeView({ + createEditor(options) { + const editor = new Editor(options); + editors.push(editor); + return editor; + }, + loadDiffFiles: () => Promise.resolve(loadedFiles), + }); + + try { + const root = createRoot(); + viewer.setup(root); + await renderItems(viewer, [ + initial, + ...Array.from({ length: 39 }, (_, index) => + makeEditFileItem(`file-${index}`, false, 30) + ), + ]); + await waitFor( + () => editors[0]?.getText() === 'one\ntwo changed\nthree\n' + ); + const editor = editors[0]; + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 'two changed'.length }, + }, + newText: 'local value', + }, + ]); + + root.scrollTop = 30_000; + dispatchScroll(root); + viewer.render(true); + await wait(0); + expect( + viewer.getRenderedItems().some((item) => item.id === initial.id) + ).toBe(false); + + const patch = createTwoFilesPatch( + 'active.txt', + 'active.txt', + loadedFiles.oldFile.contents, + loadedFiles.newFile.contents + ); + const partial = parsePatchFiles(patch, 'partial', true)[0]?.files[0]; + if (partial == null) { + throw new Error('Expected a partial diff.'); + } + partial.cacheKey = 'active:v2'; + await applyItemUpdate(viewer, { + ...initial, + fileDiff: partial, + version: 1, + }); + + root.scrollTop = 0; + dispatchScroll(root); + viewer.render(true); + await waitFor(() => editor.getText() === 'one\nexternal value\nthree\n', { + timeout: 4_000, + }); + + expect(partial.isPartial).toBe(false); + expect(viewer.getEditor('active')).toBe(editor); + expect(editors).toHaveLength(1); + editor.undo(); + expect(editor.getText()).toBe('one\nlocal value\nthree\n'); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + test('forwards file and diff annotation collections by reference', async () => { const { cleanup } = installDom(); const { editors, createEditor } = createEditorHarness(); @@ -1118,12 +1501,14 @@ describe('CodeView item edit mode', () => { const rendered = viewer.getRenderedItems()[0]; expect(rendered).toBeDefined(); - const hunkCount = edited.fileDiff.hunks.length; + const externalBefore = structuredClone(edited.fileDiff); + const externalHunks = edited.fileDiff.hunks; rendered.instance.updateRenderCache( new Map([[25, [[0, '', 'line 25 changed']]]]), 'light' ); - expect(edited.fileDiff.hunks).toHaveLength(hunkCount + 1); + expect(edited.fileDiff).toEqual(externalBefore); + expect(edited.fileDiff.hunks).toBe(externalHunks); rendered.instance.setSelectedLines({ start: 26, end: 26 }); rendered.instance.setEditorActiveLine(26); @@ -1157,6 +1542,9 @@ describe('CodeView item edit mode', () => { const { editors, createEditor } = createEditorHarness(); const viewer = new CodeView({ createEditor }); const edited = makeSessionDiffItem('edited'); + if (edited.type !== 'diff') { + throw new Error('Expected a diff edit-session item.'); + } const items: CodeViewItem[] = [ edited, ...Array.from({ length: 39 }, (_, index) => @@ -1169,15 +1557,18 @@ describe('CodeView item edit mode', () => { await renderItems(viewer, items); await wait(10); + const rendered = viewer.getRenderedItems()[0]; + const externalBefore = structuredClone(edited.fileDiff); + const externalHunks = edited.fileDiff.hunks; + // Revert one hunk mid-session: it persists as a context-only region. revertLineTen(edited, viewer); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2); - expect( - edited.type === 'diff' && edited.fileDiff.hunks[0].hunkContent[0].type - ).toBe('context'); - expect(edited.type === 'diff' && edited.fileDiff.editSessionDirty).toBe( - true - ); + const sessionDiff = getEditSessionDiff(rendered.instance); + expect(sessionDiff?.hunks).toHaveLength(2); + expect(sessionDiff?.hunks[0].hunkContent[0].type).toBe('context'); + expect(sessionDiff?.editSessionDirty).toBe(true); + expect(edited.fileDiff).toEqual(externalBefore); + expect(edited.fileDiff.hunks).toBe(externalHunks); // Scroll out (recycle): no exit recompute may run. root.scrollTop = 30_000; @@ -1185,10 +1576,10 @@ describe('CodeView item edit mode', () => { viewer.render(true); await wait(0); expect(editors[0].recycleCleanUps).toBe(1); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2); - expect(edited.type === 'diff' && edited.fileDiff.editSessionDirty).toBe( - true - ); + expect(getEditSessionDiff(rendered.instance)).toBe(sessionDiff); + expect(sessionDiff?.hunks).toHaveLength(2); + expect(sessionDiff?.editSessionDirty).toBe(true); + expect(edited.fileDiff).toEqual(externalBefore); // Scroll back: the same editor re-attaches and the session-shaped // hunks are still in place. @@ -1197,175 +1588,15 @@ describe('CodeView item edit mode', () => { viewer.render(true); await wait(0); expect(editors[0].edits.length).toBe(2); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2); - expect( - edited.type === 'diff' && edited.fileDiff.hunks[0].hunkContent[0].type - ).toBe('context'); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); - - test('finalizes session hunks when removing the only item', async () => { - const { cleanup } = installDom(); - const { createEditor } = createEditorHarness(); - const viewer = new CodeView({ createEditor }); - const edited = makeSessionDiffItem('edited'); - if (edited.type !== 'diff') { - throw new Error('Expected a diff edit-session item.'); - } - try { - viewer.setup(createRoot()); - await renderItems(viewer, [edited]); - - revertLineTen(edited, viewer); - expect(edited.fileDiff.hunks).toHaveLength(2); - expect(edited.fileDiff.editSessionDirty).toBe(true); - - expect(viewer.removeItem(edited.id)).toBe(true); - - expect(edited.fileDiff.editSessionDirty).toBeUndefined(); - expect(edited.fileDiff.hunks).toHaveLength(1); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); - - test('finalizes the last-change snapshot after a version update', async () => { - const { cleanup } = installDom(); - const { editors, createEditor } = createEditorHarness(); - const completions: CodeViewItem[] = []; - const viewer = new CodeView({ - createEditor, - onItemEditComplete(item) { - completions.push(item); - }, - }); - const edited = makeSessionDiffItem('edited'); - const replacement = makeSessionDiffItem('edited'); - replacement.version = 1; - const kept = makeEditFileItem('kept', false); - if (edited.type !== 'diff') { - throw new Error('Expected a diff edit-session item.'); - } - try { - viewer.setup(createRoot()); - await renderItems(viewer, [edited, kept]); - - revertLineTen(edited, viewer); - editors[0].emitChange({ name: 'edited.txt', contents: 'changed' }); - await renderItems(viewer, [replacement, kept]); - - // The version bump reuses the item record, so the editor and its - // session survive the update: no new editor, no completion yet. - expect(editors).toHaveLength(1); - expect(completions).toHaveLength(0); - - expect(viewer.removeItem(edited.id)).toBe(true); - - expect(completions).toHaveLength(1); - expect(completions[0]).toBe(edited); - expect(edited.fileDiff.editSessionDirty).toBeUndefined(); - expect(edited.fileDiff.hunks).toHaveLength(1); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); - - test('ending a session reconciles the item layout height', async () => { - const { cleanup } = installDom(); - const { createEditor } = createEditorHarness(); - const viewer = new CodeView({ createEditor }); - const edited = makeSessionDiffItem('edited'); - const below = makeEditFileItem('below', false, 10); - try { - viewer.setup(createRoot()); - await renderItems(viewer, [edited, below]); - await wait(10); - const heightDuring = viewer.getScrollHeight(); - - // Reverting one hunk keeps it rendered as a context-only region, so - // the mid-session layout height is unchanged. - revertLineTen(edited, viewer); - viewer.render(true); - await wait(20); - expect(viewer.getScrollHeight()).toBe(heightDuring); - - // Exit runs the recompute: the reverted region collapses away and - // the layout must shrink with it — a stale estimated height here is - // what made items overlap. - expect(viewer.updateItem({ ...edited, edit: false, version: 1 })).toBe( - true - ); - viewer.render(true); - await wait(30); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(1); - expect(viewer.getScrollHeight()).toBeLessThan(heightDuring); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); - - test('ending a session after its instance was released still recomputes', async () => { - const { cleanup } = installDom(); - const { editors, createEditor } = createEditorHarness(); - const viewer = new CodeView({ createEditor }); - const edited = makeSessionDiffItem('edited'); - const items: CodeViewItem[] = [ - edited, - ...Array.from({ length: 39 }, (_, index) => - makeEditFileItem(`file-${index}`, false, 30) - ), - ]; - try { - const root = createRoot(); - viewer.setup(root); - await renderItems(viewer, items); - await wait(10); - - revertLineTen(edited, viewer); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2); - - // Scroll the edited item out: its instance recycles and the detach - // closure is consumed non-destructively. - root.scrollTop = 30_000; - dispatchScroll(root); - viewer.render(true); - await wait(0); - expect(editors[0].recycleCleanUps).toBe(1); - - // Ending the session while released must still run the exit - // recompute: the reverted, context-only region collapses away. - expect(viewer.updateItem({ ...edited, edit: false, version: 1 })).toBe( - true - ); - viewer.render(true); - await wait(0); - expect(editors[0].fullCleanUps).toBeGreaterThanOrEqual(1); - expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(1); - expect( - edited.type === 'diff' && edited.fileDiff.editSessionDirty - ).toBeUndefined(); - - // Scrolling back renders the recomputed diff without errors. - root.scrollTop = 0; - dispatchScroll(root); - viewer.render(true); - await wait(0); const remounted = viewer .getRenderedItems() - .find((entry) => entry.id === 'edited'); - expect( - remounted?.element.shadowRoot?.querySelector('[data-error-wrapper]') - ).toBeNull(); + .find((entry) => entry.id === edited.id); + expect(remounted).toBeDefined(); + expect(getEditSessionDiff(remounted?.instance)).toBe(sessionDiff); + expect(getRendererDiff(remounted?.instance)).toBe(sessionDiff); + expect(sessionDiff?.hunks).toHaveLength(2); + expect(sessionDiff?.hunks[0].hunkContent[0].type).toBe('context'); + expect(edited.fileDiff).toEqual(externalBefore); } finally { viewer.cleanUp(); await wait(0); diff --git a/packages/diffs/test/CodeView.elementPooling.test.ts b/packages/diffs/test/CodeView.elementPooling.test.ts index 10497d47e..96241f783 100644 --- a/packages/diffs/test/CodeView.elementPooling.test.ts +++ b/packages/diffs/test/CodeView.elementPooling.test.ts @@ -319,6 +319,11 @@ describe('CodeView element pooling', () => { const firstElement = renderedItems[0].element; expect(getSpriteCount(firstElement)).toBe(1); + // Commit the initial height correction before testing the large-jump + // recycle path below. + viewer.render(true); + await wait(0); + // Jump past one: the fit-perfectly pass releases one (its shell and // sprite go to the pool, and the instance is recycled) and mounts two // into that shell, adopting the pooled sprite. The follow-up fill pass diff --git a/packages/diffs/test/CodeView.partialHydration.test.ts b/packages/diffs/test/CodeView.partialHydration.test.ts index 913852578..3863a79a9 100644 --- a/packages/diffs/test/CodeView.partialHydration.test.ts +++ b/packages/diffs/test/CodeView.partialHydration.test.ts @@ -16,23 +16,12 @@ import { wait, waitFor, } from './domHarness'; -import { assertDefined } from './testUtils'; +import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); }); -function createDeferred(): { - promise: Promise; - resolve(value: T): void; -} { - let resolve: (value: T) => void = () => {}; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - return { promise, resolve }; -} - function createPartialChange(): { oldFile: FileContents; newFile: FileContents; diff --git a/packages/diffs/test/CodeView.workerRendering.test.ts b/packages/diffs/test/CodeView.workerRendering.test.ts new file mode 100644 index 000000000..9917cf7f3 --- /dev/null +++ b/packages/diffs/test/CodeView.workerRendering.test.ts @@ -0,0 +1,669 @@ +import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test'; + +import { CodeView } from '../src/components/CodeView'; +import { + disposeHighlighter, + getSharedHighlighter, +} from '../src/highlighter/shared_highlighter'; +import type { + CodeViewItem, + DiffsHighlighter, + FileContents, + FileDiffMetadata, +} from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { renderDiffWithHighlighter } from '../src/utils/renderDiffWithHighlighter'; +import { renderFileWithHighlighter } from '../src/utils/renderFileWithHighlighter'; +import { + createRoot, + dispatchScroll, + installDom, + wait, + waitFor, +} from './domHarness'; +import { createInitializedManager, withTimeout } from './workerPoolHarness'; + +let sharedHighlighter: DiffsHighlighter; + +beforeAll(async () => { + sharedHighlighter = await getSharedHighlighter({ + themes: ['pierre-dark'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); +}); + +afterAll(async () => { + await disposeHighlighter(); +}); + +function createDiff( + newFileCacheKey: string, + newContents: string +): FileDiffMetadata { + return parseDiffFromFile( + { + name: 'pending.ts', + contents: 'const before = 0;\n', + cacheKey: 'pending:old', + }, + { + name: 'pending.ts', + contents: newContents, + cacheKey: newFileCacheKey, + } + ); +} + +function getRenderedText( + viewer: CodeView, + id: string +): string | undefined { + return viewer + .getRenderedItems() + .find((item) => item.id === id) + ?.element.shadowRoot?.textContent?.trim(); +} + +function getRenderedSlotText( + viewer: CodeView, + id: string +): string | undefined { + return viewer + .getRenderedItems() + .find((item) => item.id === id) + ?.element.textContent?.trim(); +} + +function getRenderedHeaderPrefix( + viewer: CodeView, + id: string +): string | undefined { + const element = viewer + .getRenderedItems() + .find((item) => item.id === id) + ?.element.querySelector('[slot="header-prefix"]'); + return element?.innerText; +} + +function getItemTop( + viewer: CodeView, + id: string +): number { + const top = viewer.getTopForItem(id); + if (top == null) { + throw new Error(`Expected CodeView layout for item "${id}"`); + } + return top; +} + +function getRenderedDiffForTest( + instance: object +): FileDiffMetadata | undefined { + return Reflect.get(instance, 'renderedDiff') as FileDiffMetadata | undefined; +} + +function getRenderedFileForTest(instance: object): FileContents | undefined { + return Reflect.get(instance, 'renderedFile') as FileContents | undefined; +} + +describe('CodeView worker rendering', () => { + test('ignores a worker result after its collapsed file is removed', async () => { + const { cleanup } = installDom(); + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const viewer = new CodeView( + { + theme: 'pierre-dark', + }, + manager + ); + const file: FileContents = { + name: 'removed.ts', + contents: 'const removed = true;\n', + cacheKey: 'removed:file', + }; + const item: CodeViewItem = { + id: 'file:removed', + type: 'file', + file, + collapsed: true, + }; + const instanceChanged = spyOn(viewer, 'instanceChanged'); + + try { + viewer.setup(createRoot()); + viewer.setItems([item]); + viewer.render(true); + + const request = await withTimeout(worker.waitForFileRequest()); + viewer.setItems([]); + viewer.render(true); + instanceChanged.mockClear(); + + const renderOptions = manager.getFileRenderOptions(); + worker.respond({ + type: 'success', + requestType: 'file', + id: request.id, + result: renderFileWithHighlighter( + file, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + await wait(0); + + expect(viewer.getRenderedItems()).toEqual([]); + expect(instanceChanged).not.toHaveBeenCalled(); + } finally { + instanceChanged.mockRestore(); + viewer.cleanUp(); + manager.terminate(); + await wait(0); + cleanup(); + } + }); + + test('keeps layout matched to the displayed diff while its replacement is highlighted', async () => { + const { cleanup } = installDom(); + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const viewer = new CodeView( + { + renderAnnotation: (annotation) => { + const element = document.createElement('span'); + element.textContent = annotation.metadata; + return element; + }, + renderHeaderPrefix: (file) => `header:${file.cacheKey}`, + stickyHeaders: false, + theme: 'pierre-dark', + }, + manager + ); + const shortDiff = createDiff('pending:short', 'const shortValue = 1;\n'); + const tallDiff = parseDiffFromFile( + { + name: 'pending.ts', + contents: 'const before = 0;\nconst removed = 1;\n', + cacheKey: 'pending:tall-old', + }, + { + name: 'pending.ts', + contents: + Array.from( + { length: 120 }, + (_, index) => `const tallValue${index + 1} = ${index + 1};` + ).join('\n') + '\n', + cacheKey: 'pending:tall', + } + ); + const follower: CodeViewItem = { + id: 'file:follower', + type: 'file', + file: { + name: 'follower.txt', + lang: 'text', + contents: Array.from( + { length: 80 }, + (_, index) => `follower ${index + 1}` + ).join('\n'), + }, + }; + const shortItem: CodeViewItem = { + id: 'diff:pending', + type: 'diff', + fileDiff: shortDiff, + version: 0, + annotations: [ + { + side: 'additions', + lineNumber: 1, + metadata: 'annotation:short', + }, + ], + }; + const tallItem: CodeViewItem = { + id: shortItem.id, + type: 'diff', + fileDiff: tallDiff, + version: 1, + annotations: [ + { + side: 'additions', + lineNumber: 1, + metadata: 'annotation:tall', + }, + ], + }; + + try { + const root = createRoot({ height: 120 }); + viewer.setup(root); + viewer.setItems([shortItem, follower]); + viewer.render(true); + + const shortRequest = await withTimeout(worker.waitForDiffRequest()); + const renderOptions = manager.getDiffRenderOptions(); + worker.respond({ + type: 'success', + requestType: 'diff', + id: shortRequest.id, + result: renderDiffWithHighlighter( + shortDiff, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + viewer.render(true); + await waitFor( + () => + getRenderedText(viewer, shortItem.id)?.includes('shortValue') === true + ); + expect(getRenderedText(viewer, shortItem.id)).toContain('shortValue'); + expect(getRenderedHeaderPrefix(viewer, shortItem.id)).toBe( + `header:${shortDiff.cacheKey}` + ); + expect(getRenderedSlotText(viewer, shortItem.id)).toContain( + 'annotation:short' + ); + + const shortScrollHeight = viewer.getScrollHeight(); + const followerTopWithShortDiff = getItemTop(viewer, follower.id); + + viewer.setItems([tallItem, follower]); + viewer.render(); + + await waitFor(() => worker.diffRequestCount === 2); + expect(worker.diffRequestCount).toBe(2); + const tallRequest = await withTimeout(worker.waitForDiffRequest()); + expect(tallRequest.diff.cacheKey).toBe(tallDiff.cacheKey); + expect(viewer.getItem(shortItem.id)).toBe(tallItem); + expect(getRenderedText(viewer, shortItem.id)).toContain('shortValue'); + expect(getRenderedText(viewer, shortItem.id)).not.toContain( + 'tallValue120' + ); + const pendingItem = viewer + .getRenderedItems() + .find((item) => item.id === shortItem.id); + expect(pendingItem?.type).toBe('diff'); + if (pendingItem?.type !== 'diff') { + throw new Error('Expected the diff item to remain rendered'); + } + const shortPreparedHeight = pendingItem.instance.getVirtualizedHeight(); + expect(pendingItem.item).toBe(tallItem); + expect(pendingItem.version).toBe(1); + expect(getRenderedDiffForTest(pendingItem.instance)).toBe(shortDiff); + const shortLineIndex = pendingItem.instance.getLineIndex( + 120, + 'additions' + ); + expect(getRenderedHeaderPrefix(viewer, shortItem.id)).toBe( + `header:${shortDiff.cacheKey}` + ); + expect(getRenderedSlotText(viewer, shortItem.id)).toContain( + 'annotation:tall' + ); + expect(getRenderedSlotText(viewer, shortItem.id)).not.toContain( + 'annotation:short' + ); + expect(viewer.getScrollHeight()).toBe(shortScrollHeight); + expect(getItemTop(viewer, follower.id)).toBe(followerTopWithShortDiff); + + root.scrollTop = followerTopWithShortDiff; + dispatchScroll(root); + viewer.render(true); + const followerOffsetBefore = + followerTopWithShortDiff - viewer.getScrollTop(); + + worker.respond({ + type: 'success', + requestType: 'diff', + id: tallRequest.id, + result: renderDiffWithHighlighter( + tallDiff, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + + // The worker response only schedules the render. Until that render runs, + // both the DOM and layout still describe the short diff. + expect(getRenderedText(viewer, shortItem.id)).toContain('shortValue'); + expect(getItemTop(viewer, follower.id)).toBe(followerTopWithShortDiff); + const itemBeforeReplacementRender = viewer + .getRenderedItems() + .find((item) => item.id === shortItem.id); + expect(itemBeforeReplacementRender?.type).toBe('diff'); + if (itemBeforeReplacementRender?.type !== 'diff') { + throw new Error('Expected the short diff to remain rendered'); + } + expect( + itemBeforeReplacementRender.instance.getLineIndex(120, 'additions') + ).toEqual(shortLineIndex); + expect(getRenderedDiffForTest(itemBeforeReplacementRender.instance)).toBe( + shortDiff + ); + + const tallPreparedHeight = + itemBeforeReplacementRender.instance.updateCodeViewLayout( + tallDiff, + viewer.getLocalTopForInstance(itemBeforeReplacementRender.instance), + undefined, + tallItem.annotations + ); + expect(tallPreparedHeight).toBeGreaterThan(shortPreparedHeight); + expect(getRenderedDiffForTest(itemBeforeReplacementRender.instance)).toBe( + shortDiff + ); + + await waitFor( + () => + getRenderedText(viewer, shortItem.id)?.includes('tallValue120') === + true + ); + + const followerTopWithTallDiff = getItemTop(viewer, follower.id); + expect(getRenderedText(viewer, shortItem.id)).toContain('tallValue120'); + expect(getRenderedText(viewer, shortItem.id)).not.toContain('shortValue'); + const replacementItem = viewer + .getRenderedItems() + .find((item) => item.id === shortItem.id); + expect(replacementItem?.type).toBe('diff'); + if (replacementItem?.type !== 'diff') { + throw new Error('Expected the replacement diff to be rendered'); + } + expect(replacementItem.item.fileDiff).toBe(tallDiff); + expect(replacementItem.version).toBe(1); + expect(getRenderedDiffForTest(replacementItem.instance)).toBe(tallDiff); + expect( + replacementItem.instance.getLineIndex(120, 'additions') + ).not.toEqual(shortLineIndex); + expect(getRenderedHeaderPrefix(viewer, shortItem.id)).toBe( + `header:${tallDiff.cacheKey}` + ); + expect(getRenderedSlotText(viewer, shortItem.id)).toContain( + 'annotation:tall' + ); + expect(getRenderedSlotText(viewer, shortItem.id)).not.toContain( + 'annotation:short' + ); + expect(viewer.getScrollHeight()).toBeGreaterThan(shortScrollHeight); + expect(followerTopWithTallDiff).toBeGreaterThan(followerTopWithShortDiff); + expect(followerTopWithTallDiff - viewer.getScrollTop()).toBe( + followerOffsetBefore + ); + } finally { + viewer.cleanUp(); + manager.terminate(); + await wait(0); + cleanup(); + } + }); + + test('keeps file layout matched to the displayed file while its replacement is highlighted', async () => { + const { cleanup } = installDom(); + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const viewer = new CodeView( + { + disableFileHeader: true, + stickyHeaders: false, + theme: 'pierre-dark', + }, + manager + ); + const shortFile: FileContents = { + name: 'short.ts', + contents: 'const shortValue = 1;\n', + cacheKey: 'file-layout:short', + }; + const tallFile: FileContents = { + name: 'tall.ts', + contents: + Array.from( + { length: 120 }, + (_, index) => `const tallValue${index + 1} = ${index + 1};` + ).join('\n') + '\n', + cacheKey: 'file-layout:tall', + }; + const shortItem: CodeViewItem = { + id: 'file:pending', + type: 'file', + file: shortFile, + version: 0, + }; + const tallItem: CodeViewItem = { + ...shortItem, + file: tallFile, + version: 1, + }; + const follower: CodeViewItem = { + id: 'file:pending-follower', + type: 'file', + file: { + name: 'follower.txt', + lang: 'text', + contents: 'follower\n', + }, + }; + + try { + const root = createRoot({ height: 120 }); + viewer.setup(root); + viewer.setItems([shortItem, follower]); + viewer.render(true); + + const shortRequest = await withTimeout(worker.waitForFileRequest()); + const renderOptions = manager.getFileRenderOptions(); + worker.respond({ + type: 'success', + requestType: 'file', + id: shortRequest.id, + result: renderFileWithHighlighter( + shortFile, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + viewer.render(true); + await waitFor( + () => + getRenderedText(viewer, shortItem.id)?.includes('shortValue') === true + ); + + const shortScrollHeight = viewer.getScrollHeight(); + const followerTopWithShortFile = getItemTop(viewer, follower.id); + viewer.setItems([tallItem, follower]); + viewer.render(); + + await waitFor(() => worker.fileRequestCount === 2); + const tallRequest = await withTimeout(worker.waitForFileRequest()); + expect(getRenderedText(viewer, shortItem.id)).toContain('shortValue'); + expect(getRenderedText(viewer, shortItem.id)).not.toContain( + 'tallValue120' + ); + expect(viewer.getScrollHeight()).toBe(shortScrollHeight); + expect(getItemTop(viewer, follower.id)).toBe(followerTopWithShortFile); + const pendingItem = viewer + .getRenderedItems() + .find((item) => item.id === shortItem.id); + expect(pendingItem?.type).toBe('file'); + if (pendingItem?.type !== 'file') { + throw new Error('Expected the file item to remain rendered'); + } + expect(getRenderedFileForTest(pendingItem.instance)).toBe(shortFile); + + worker.respond({ + type: 'success', + requestType: 'file', + id: tallRequest.id, + result: renderFileWithHighlighter( + tallFile, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + await waitFor( + () => + getRenderedText(viewer, shortItem.id)?.includes('tallValue120') === + true + ); + + const replacementItem = viewer + .getRenderedItems() + .find((item) => item.id === shortItem.id); + expect(replacementItem?.type).toBe('file'); + if (replacementItem?.type !== 'file') { + throw new Error('Expected the replacement file to be rendered'); + } + expect(getRenderedFileForTest(replacementItem.instance)).toBe(tallFile); + expect(viewer.getScrollHeight()).toBeGreaterThan(shortScrollHeight); + expect(getItemTop(viewer, follower.id)).toBeGreaterThan( + followerTopWithShortFile + ); + } finally { + viewer.cleanUp(); + manager.terminate(); + await wait(0); + cleanup(); + } + }); + + test('updates replacement layout before remounting a recycled diff', async () => { + const { cleanup } = installDom(); + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const viewer = new CodeView( + { + disableFileHeader: true, + stickyHeaders: false, + theme: 'pierre-dark', + }, + manager + ); + const shortDiff = createDiff('recycle:short', 'const shortValue = 1;\n'); + const tallDiff = createDiff( + 'recycle:tall', + Array.from( + { length: 120 }, + (_, index) => `const tallValue${index + 1} = ${index + 1};` + ).join('\n') + '\n' + ); + const diffItem: CodeViewItem = { + id: 'diff:recycle', + type: 'diff', + fileDiff: shortDiff, + version: 0, + }; + const replacementItem: CodeViewItem = { + id: diffItem.id, + type: 'diff', + fileDiff: tallDiff, + version: 1, + }; + const follower: CodeViewItem = { + id: 'file:recycle-follower', + type: 'file', + file: { + name: 'recycle-follower.txt', + lang: 'text', + contents: Array.from( + { length: 400 }, + (_, index) => `follower ${index + 1}` + ).join('\n'), + }, + }; + + try { + const root = createRoot({ height: 120 }); + viewer.setup(root); + viewer.setItems([diffItem, follower]); + viewer.render(true); + + const shortRequest = await withTimeout(worker.waitForDiffRequest()); + const renderOptions = manager.getDiffRenderOptions(); + worker.respond({ + type: 'success', + requestType: 'diff', + id: shortRequest.id, + result: renderDiffWithHighlighter( + shortDiff, + sharedHighlighter, + renderOptions + ), + options: renderOptions, + sentAt: Date.now(), + }); + viewer.render(true); + await waitFor( + () => + getRenderedText(viewer, diffItem.id)?.includes('shortValue') === true + ); + + const shortScrollHeight = viewer.getScrollHeight(); + const followerTopWithShortDiff = getItemTop(viewer, follower.id); + viewer.setItems([replacementItem, follower]); + viewer.render(); + await waitFor(() => worker.diffRequestCount === 2); + expect(viewer.getItem(diffItem.id)).toBe(replacementItem); + const pendingItem = viewer + .getRenderedItems() + .find((item) => item.id === diffItem.id); + expect(pendingItem?.type).toBe('diff'); + if (pendingItem?.type !== 'diff') { + throw new Error('Expected the replacement item to remain mounted'); + } + expect(pendingItem.item).toBe(replacementItem); + expect(pendingItem.version).toBe(1); + expect(getRenderedText(viewer, diffItem.id)).toContain('shortValue'); + + root.scrollTop = 4_000; + dispatchScroll(root); + viewer.render(true); + await waitFor( + () => !viewer.getRenderedItems().some((item) => item.id === diffItem.id) + ); + + root.scrollTop = 0; + dispatchScroll(root); + viewer.render(true); + + const renderedItem = viewer + .getRenderedItems() + .find((item) => item.id === diffItem.id); + expect(renderedItem?.type).toBe('diff'); + if (renderedItem?.type !== 'diff') { + throw new Error('Expected the recycled diff to remount'); + } + expect(renderedItem.item.fileDiff).toBe(tallDiff); + expect(renderedItem.version).toBe(1); + expect(getRenderedText(viewer, diffItem.id)).toContain('tallValue1'); + expect(getRenderedText(viewer, diffItem.id)).not.toContain('shortValue'); + expect(viewer.getScrollHeight()).toBeGreaterThan(shortScrollHeight); + expect(getItemTop(viewer, follower.id)).toBeGreaterThan( + followerTopWithShortDiff + ); + } finally { + viewer.cleanUp(); + manager.terminate(); + await wait(0); + cleanup(); + } + }); +}); diff --git a/packages/diffs/test/DiffHunksRendererRecompute.test.ts b/packages/diffs/test/DiffHunksRendererRecompute.test.ts index 74ad7575d..95303ca3a 100644 --- a/packages/diffs/test/DiffHunksRendererRecompute.test.ts +++ b/packages/diffs/test/DiffHunksRendererRecompute.test.ts @@ -306,12 +306,56 @@ describe('DiffHunksRenderer edit-session hunk updates', () => { ).toEqual([12]); }); - test('session exit restores highlighting for realigned rows', async () => { - // Realignment plain-fills lines inside the changed window (their old - // slots were legitimately rewritten mid-pass), and hidden rows are never - // re-tokenized by the editor. Refreshing the highlighted result at - // exit — as FileDiff.completeEditSession does — must restore full - // highlighting without ever rendering the interim view unhighlighted. + test('pressing Enter keeps the edited line highlighted', async () => { + const renderer = new DiffHunksRenderer({ + theme: 'github-light', + diffStyle: 'split', + }); + const externalDiff = parseDiffFromFile( + { name: 'comment.ts', contents: 'const oldValue = true;\n' }, + { name: 'comment.ts', contents: 'const newValue = true;\n' } + ); + await renderer.asyncRender(externalDiff); + renderer.renderDiff(externalDiff); + const diff = { ...externalDiff, cacheKey: undefined }; + renderer.beginEditSession(diff, externalDiff); + renderer.renderDiff(diff); + + // Match the editor sequence: empty the document, then type a comment on + // the remaining editable row before pressing Enter. + renderer.updateRenderCache(makeDirtyLines([[0, '']]), 'light', true); + renderer.applyDocumentChange(makeTextDocumentFromText('')); + renderer.updateRenderCache( + new Map([[0, [[0, '#737373', '// test']]]]), + 'light' + ); + renderer.updateRenderCache( + new Map([ + [0, [[0, '#737373', '// test']]], + [1, [[0, '', '']]], + ]), + 'light', + true + ); + renderer.applyDocumentChange(makeTextDocumentFromText('// test\n')); + + const result = renderer.renderDiff(diff); + const commentRow = collectAllElements( + result?.additionsContentAST ?? [] + ).find( + (node) => + node.properties?.['data-line'] === 1 && + hastTextContent(node) === '// test' + ); + + expect(commentRow).toBeDefined(); + expect(JSON.stringify(commentRow)).toContain('color:#737373'); + }); + + test('session exit retains highlighting for realigned rows', async () => { + // Structural token rows are held until the old highlighted cache has been + // realigned, so shifted rows below the edit remain highlighted before and + // after FileDiff refreshes the full result at session exit. const trailing = 'const last = true;'; const oldContents = 'first\n' + '\n'.repeat(9) + trailing + '\n'; const newContents = 'changed\n' + '\n'.repeat(9) + trailing + '\n'; @@ -328,10 +372,9 @@ describe('DiffHunksRenderer edit-session hunk updates', () => { renderer.beginEditSession(); const editedLines = newContents.split('\n'); - // Mirror the dirty-token pass that precedes applyDocumentChange: the - // tokenizer rewrites the shifted trailing line's slot (old index 10) - // with its post-edit content, which is what strands the moved line in - // the realign's plain-filled window. + // Mirror the dirty-token pass that precedes applyDocumentChange. The + // blank row now at index 10 must not overwrite the highlighted trailing + // row that still occupies that index in the pre-edit cache. renderer.updateRenderCache(makeDirtyLines([[10, '']]), 'light', true); editedLines.splice(1, 0, ''); renderer.applyDocumentChange(makeTextDocument(editedLines)); @@ -351,10 +394,12 @@ describe('DiffHunksRenderer edit-session hunk updates', () => { ) .map((node) => hastTextContent(node).replace(/\n$/, '')); - // The exit repaint runs before the fresh highlight lands: it must keep - // serving the current result (no un-highlighted flash), with only the - // realigned window plain. - expect(styledRowTexts(renderer.renderDiff(diff))).toEqual(['changed']); + // The exit repaint runs before the fresh highlight lands and must keep + // serving the fully highlighted current result without a plain-text flash. + expect(styledRowTexts(renderer.renderDiff(diff))).toEqual([ + 'changed', + trailing, + ]); await refresh; expect(styledRowTexts(renderer.renderDiff(diff))).toEqual([ diff --git a/packages/diffs/test/File.editSessionOwnership.test.ts b/packages/diffs/test/File.editSessionOwnership.test.ts new file mode 100644 index 000000000..127476715 --- /dev/null +++ b/packages/diffs/test/File.editSessionOwnership.test.ts @@ -0,0 +1,366 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { disposeHighlighter, File, isFileAnnotationCollection } from '../src'; +import { Editor } from '../src/editor/editor'; +import type { FileContents, LineAnnotation } from '../src/types'; +import { installDom, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +class TestFile extends File { + getLatestFileForTest(): FileContents | undefined { + return this.getLatestFile(); + } + + getRenderedFileForTest(): FileContents | undefined { + return this.getRenderedFile(); + } + + getRendererFileForTest(): FileContents | undefined { + return this.fileRenderer.fileCache; + } +} + +const EXTERNAL_FILE: FileContents = { + name: 'session.ts', + contents: 'alpha\nbravo\n', + cacheKey: 'external:file-v1', +}; + +async function createFixture(options?: { + lineAnnotations?: LineAnnotation[]; + onChange?( + contents: string, + lineAnnotations: LineAnnotation[] | undefined + ): void; +}) { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const externalFile = { ...EXTERNAL_FILE }; + const instance = new TestFile({ + disableErrorHandling: true, + disableFileHeader: true, + }); + const editor = new Editor({ + onChange(file, lineAnnotations) { + options?.onChange?.( + file.contents, + lineAnnotations == null || isFileAnnotationCollection(lineAnnotations) + ? lineAnnotations + : undefined + ); + }, + }); + + instance.render({ + file: externalFile, + fileContainer, + forceRender: true, + lineAnnotations: options?.lineAnnotations, + }); + editor.edit(instance); + await waitFor(() => editor.getText() === externalFile.contents, { + timeout: 4_000, + }); + + return { + dom, + editor, + externalFile, + fileContainer, + instance, + cleanup() { + editor.cleanUp(); + instance.cleanUp(); + dom.cleanup(); + }, + }; +} + +function replaceDocument(editor: Editor, contents: string): void { + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { + line: Number.MAX_SAFE_INTEGER, + character: Number.MAX_SAFE_INTEGER, + }, + }, + newText: contents, + }, + ]); +} + +describe('editing a File without changing its input', () => { + test('editing methods throw before editing starts', () => { + const dom = installDom(); + const fileContainer = document.createElement('div'); + const externalFile = { ...EXTERNAL_FILE }; + const instance = new TestFile({ + disableErrorHandling: true, + disableFileHeader: true, + }); + try { + instance.render({ file: externalFile, fileContainer, forceRender: true }); + expect(() => + instance.updateRenderCache(new Map([[0, [[0, '', 'edited']]]]), 'dark') + ).toThrow('File.updateRenderCache: requires an active edit session'); + expect(() => + instance.applyDocumentChange({ + lineCount: 1, + getLineText: () => 'edited', + getText: () => 'edited', + }) + ).toThrow('File.applyDocumentChange: requires an active edit session'); + expect(externalFile).toEqual(EXTERNAL_FILE); + } finally { + instance.cleanUp(); + dom.cleanup(); + } + }); + + test('attaching an editor creates a separate file without a cache key', async () => { + const fixture = await createFixture(); + try { + const editSessionFile = fixture.instance.getLatestFileForTest(); + expect(fixture.instance.file).toBe(fixture.externalFile); + expect(editSessionFile).not.toBe(fixture.externalFile); + expect(editSessionFile?.cacheKey).toBeUndefined(); + expect(editSessionFile?.contents).toBe(fixture.externalFile.contents); + expect(fixture.instance.getRenderedFileForTest()).toBe(editSessionFile); + expect(fixture.instance.getRendererFileForTest()).toBe(editSessionFile); + } finally { + fixture.cleanup(); + } + }); + + test('edits and annotation renders never change the external file', async () => { + const fixture = await createFixture(); + const externalBefore = structuredClone(fixture.externalFile); + try { + replaceDocument(fixture.editor, 'edited\nbravo\ncharlie\n'); + expect(fixture.editor.getFile()?.cacheKey).toBeUndefined(); + + fixture.instance.render({ + file: fixture.externalFile, + fileContainer: fixture.fileContainer, + forceRender: true, + lineAnnotations: [{ lineNumber: 2, metadata: undefined }], + }); + + await waitFor(() => { + const text = fixture.fileContainer.shadowRoot?.textContent ?? ''; + return ( + text.includes('edited') && + text.includes('charlie') && + fixture.fileContainer.shadowRoot?.querySelector( + '[data-line-annotation]' + ) != null + ); + }); + const renderedText = fixture.fileContainer.shadowRoot?.textContent ?? ''; + expect(renderedText).toContain('edited'); + expect(renderedText).toContain('charlie'); + expect( + fixture.fileContainer.shadowRoot?.querySelector( + '[data-line-annotation]' + ) + ).not.toBeNull(); + expect(fixture.instance.getLatestFileForTest()?.contents).toBe( + 'edited\nbravo\ncharlie\n' + ); + expect(fixture.instance.getRendererFileForTest()).toBe( + fixture.instance.getLatestFileForTest() + ); + expect(fixture.externalFile).toEqual(externalBefore); + } finally { + fixture.cleanup(); + } + }); + + test('synchronously rendering moved annotations observes the edited document', async () => { + let contents = EXTERNAL_FILE.contents; + let lineAnnotations: LineAnnotation[] = [ + { lineNumber: 2, metadata: undefined }, + ]; + const fixture = await createFixture({ + lineAnnotations, + onChange(nextContents, nextLineAnnotations) { + contents = nextContents; + lineAnnotations = nextLineAnnotations ?? []; + fixture.instance.render({ + file: fixture.externalFile, + fileContainer: fixture.fileContainer, + forceRender: true, + lineAnnotations, + }); + }, + }); + + try { + fixture.editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: '\n\n', + }, + ]); + + expect(lineAnnotations).toEqual([{ lineNumber: 4, metadata: undefined }]); + expect(contents).toBe('\n\nalpha\nbravo\n'); + expect(fixture.editor.getText()).toBe('\n\nalpha\nbravo\n'); + expect( + fixture.fileContainer.shadowRoot?.querySelector('[data-line="4"]') + ).not.toBeNull(); + expect( + fixture.fileContainer.shadowRoot?.querySelector( + '[data-line-annotation]' + ) + ).not.toBeNull(); + } finally { + fixture.cleanup(); + } + }); + + test('rendering another file with the same cache key preserves current edits', async () => { + const fixture = await createFixture(); + try { + replaceDocument(fixture.editor, 'edited\n'); + const editSessionFile = fixture.instance.getLatestFileForTest(); + + fixture.instance.render({ + file: { ...fixture.externalFile }, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + + expect(fixture.instance.file).toBe(fixture.externalFile); + expect(fixture.instance.getLatestFileForTest()).toBe(editSessionFile); + expect(fixture.editor.getText()).toBe('edited\n'); + expect(fixture.externalFile).toEqual(EXTERNAL_FILE); + } finally { + fixture.cleanup(); + } + }); + + test('replacing the file contents can be undone as one change', async () => { + const changes: string[] = []; + const fixture = await createFixture({ + onChange: (contents) => changes.push(contents), + }); + const initialBefore = structuredClone(fixture.externalFile); + const replacement: FileContents = { + name: 'session.ts', + contents: 'charlie\n', + cacheKey: 'external:file-v2', + }; + const replacementBefore = structuredClone(replacement); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + fixture.instance.render({ + file: replacement, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getText() === 'charlie\n', { + timeout: 4_000, + }); + + expect(fixture.instance.file).toBe(replacement); + expect(fixture.instance.getLatestFileForTest()).not.toBe(replacement); + expect(fixture.instance.getLatestFileForTest()?.cacheKey).toBeUndefined(); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\nbravo\n'); + expect(fixture.externalFile).toEqual(initialBefore); + expect(replacement).toEqual(replacementBefore); + } finally { + fixture.cleanup(); + } + }); + + test('a file-name change that is replaced immediately does not clear undo history', async () => { + const changes: string[] = []; + const fixture = await createFixture({ + onChange: (contents) => changes.push(contents), + }); + const intermediate: FileContents = { + name: 'intermediate.js', + contents: 'intermediate\n', + cacheKey: 'external:file-v2', + }; + const replacement: FileContents = { + name: EXTERNAL_FILE.name, + contents: 'charlie\n', + cacheKey: 'external:file-v3', + }; + + try { + replaceDocument(fixture.editor, 'bravo\n'); + fixture.instance.render({ + file: intermediate, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + fixture.instance.render({ + file: replacement, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getText() === 'charlie\n', { + timeout: 4_000, + }); + + expect(changes).toEqual(['bravo\n', 'charlie\n']); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe(EXTERNAL_FILE.contents); + } finally { + fixture.cleanup(); + } + }); + + test('changing the file name or language clears undo history', async () => { + for (const replacement of [ + { + name: 'renamed.ts', + contents: 'charlie\n', + cacheKey: 'external:renamed', + }, + { + name: 'session.ts', + lang: 'javascript' as const, + contents: 'charlie\n', + cacheKey: 'external:javascript', + }, + ]) { + const fixture = await createFixture(); + try { + replaceDocument(fixture.editor, 'bravo\n'); + fixture.instance.render({ + file: replacement, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getText() === 'charlie\n', { + timeout: 4_000, + }); + expect(fixture.editor.canUndo).toBe(false); + expect(fixture.editor.canRedo).toBe(false); + } finally { + fixture.cleanup(); + } + } + }); +}); diff --git a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts new file mode 100644 index 000000000..8e369bd7b --- /dev/null +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -0,0 +1,485 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; +import { Editor } from '../src/editor/editor'; +import type { + DiffsEditor, + DiffsTextDocument, + FileContents, + FileDiffMetadata, + HighlightedToken, +} from '../src/types'; +import { installDom, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +class TestFileDiff extends FileDiff { + getLatestDiffForTest(): FileDiffMetadata | undefined { + return this.getLatestDiff(); + } + + getRendererDiffForTest(): FileDiffMetadata | undefined { + return this.hunksRenderer.diffCache; + } + + isEditorRenderReadyForTest(): boolean { + return this.hunksRenderer.editorRenderReady(); + } +} + +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + +function createExternalDiff(): FileDiffMetadata { + const fileDiff = parseDiffFromFile( + { name: 'session.ts', contents: 'alpha\nold value\nomega\n' }, + { name: 'session.ts', contents: 'alpha\nnew value\nomega\n' } + ); + fileDiff.cacheKey = 'external:session-v1'; + return fileDiff; +} + +function captureExternalDiffState(fileDiff: FileDiffMetadata) { + return { + value: structuredClone(fileDiff), + additionLines: fileDiff.additionLines, + deletionLines: fileDiff.deletionLines, + hunks: fileDiff.hunks, + hunkItems: [...fileDiff.hunks], + }; +} + +function expectExternalDiffUnchanged( + instance: TestFileDiff, + externalDiff: FileDiffMetadata, + before: ReturnType +): void { + expect(instance.fileDiff).toBe(externalDiff); + expect(externalDiff.additionLines).toBe(before.additionLines); + expect(externalDiff.deletionLines).toBe(before.deletionLines); + expect(externalDiff.hunks).toBe(before.hunks); + for (const [index, hunk] of before.hunkItems.entries()) { + expect(externalDiff.hunks[index]).toBe(hunk); + } + expect(externalDiff).toEqual(before.value); +} + +function makeDirtyLines( + edits: ReadonlyArray<[number, string]> +): Map { + return new Map(edits.map(([line, text]) => [line, [[0, '', text]]])); +} + +function makeTextDocument(lines: string[]): DiffsTextDocument { + return { + lineCount: lines.length, + getText: () => lines.join(''), + getLineText: (lineNumber: number, includeLineBreak = false) => { + const line = lines[lineNumber] ?? ''; + return includeLineBreak ? line : line.replace(/\r?\n$/, ''); + }, + }; +} + +async function createAttachedFixture(): Promise<{ + cleanup(): void; + detach(recycle?: boolean): void; + externalDiff: FileDiffMetadata; + fileContainer: HTMLElement; + instance: TestFileDiff; +}> { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const externalDiff = createExternalDiff(); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + }); + + instance.render({ + fileDiff: externalDiff, + fileContainer, + forceRender: true, + }); + const detach = instance.attachEditor(createEditorStub()); + + await waitFor( + () => { + const sessionDiff = instance.getLatestDiffForTest(); + return ( + sessionDiff != null && + sessionDiff !== externalDiff && + instance.isEditorRenderReadyForTest() + ); + }, + { timeout: 4_000 } + ); + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff).not.toBe(externalDiff); + expect(instance.isEditorRenderReadyForTest()).toBe(true); + + return { + cleanup() { + instance.cleanUp(); + dom.cleanup(); + }, + detach, + externalDiff, + fileContainer, + instance, + }; +} + +describe('FileDiff edit-session ownership', () => { + test('mutation entry points reject writes without an edit session', () => { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const externalDiff = createExternalDiff(); + const externalBefore = captureExternalDiffState(externalDiff); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + }); + + try { + instance.render({ + fileDiff: externalDiff, + fileContainer, + forceRender: true, + }); + + expect(() => + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ) + ).toThrow('FileDiff.updateRenderCache: requires an active edit session'); + expect(() => + instance.applyDocumentChange( + makeTextDocument(['alpha\n', 'inserted\n', 'new value\n', 'omega\n']) + ) + ).toThrow( + 'FileDiff.applyDocumentChange: requires an active edit session' + ); + + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + instance.cleanUp(); + dom.cleanup(); + } + }); + + test('attach creates a private keyless shallow session diff', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + try { + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + if (sessionDiff == null) return; + + expect(sessionDiff).not.toBe(externalDiff); + expect(sessionDiff.cacheKey).toBeUndefined(); + expect(externalDiff.cacheKey).toBe('external:session-v1'); + expect(sessionDiff.additionLines).toBe(externalDiff.additionLines); + expect(sessionDiff.deletionLines).toBe(externalDiff.deletionLines); + expect(sessionDiff.hunks).toBe(externalDiff.hunks); + expect(sessionDiff.hunks[0]).toBe(externalDiff.hunks[0]); + } finally { + detach(); + fixture.cleanup(); + } + }); + + test('a same-line edit copies addition lines and keeps hunks shared', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + try { + const sessionBefore = instance.getLatestDiffForTest(); + expect(sessionBefore).toBeDefined(); + if (sessionBefore == null) return; + expect(sessionBefore.additionLines).toBe(externalDiff.additionLines); + expect(sessionBefore.hunks).toBe(externalDiff.hunks); + + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ); + + const sessionAfter = instance.getLatestDiffForTest(); + expect(sessionAfter).toBe(sessionBefore); + expect(sessionAfter?.additionLines).not.toBe(externalDiff.additionLines); + expect(sessionAfter?.additionLines[1]).toBe('edited value\n'); + expect(sessionAfter?.deletionLines).toBe(externalDiff.deletionLines); + expect(sessionAfter?.hunks).toBe(externalDiff.hunks); + expect(sessionAfter?.hunks[0]).toBe(externalDiff.hunks[0]); + expect(sessionAfter?.editSessionDirty).toBe(true); + expect(instance.getRendererDiffForTest()).toBe(sessionAfter); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); + + test('a structural edit rebuilds an owned hunk graph', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + try { + const sessionBefore = instance.getLatestDiffForTest(); + expect(sessionBefore).toBeDefined(); + if (sessionBefore == null) return; + expect(sessionBefore.hunks).toBe(externalDiff.hunks); + + instance.applyDocumentChange( + makeTextDocument(['alpha\n', 'inserted\n', 'new value\n', 'omega\n']) + ); + + const sessionAfter = instance.getLatestDiffForTest(); + expect(sessionAfter).toBe(sessionBefore); + expect(sessionAfter?.additionLines).not.toBe(externalDiff.additionLines); + expect(sessionAfter?.additionLines.join('')).toBe( + 'alpha\ninserted\nnew value\nomega\n' + ); + expect(sessionAfter?.deletionLines).toBe(externalDiff.deletionLines); + expect(sessionAfter?.hunks).not.toBe(externalDiff.hunks); + expect(sessionAfter?.hunks[0]).not.toBe(externalDiff.hunks[0]); + expect(sessionAfter?.cacheKey).toBeUndefined(); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); + + for (const [triggerName, triggerRender] of [ + [ + 'an internal rerender', + ({ instance }: Awaited>) => + instance.rerender(), + ], + [ + 'a theme-cache rerender', + ({ instance }: Awaited>) => + instance.onThemeChange(), + ], + [ + 'an overlapping viewport rerender', + ({ + externalDiff, + fileContainer, + instance, + }: Awaited>) => { + instance.render({ + fileDiff: externalDiff, + fileContainer, + forceRender: true, + renderRange: { + startingLine: 0, + totalLines: 2, + bufferBefore: 0, + bufferAfter: 0, + }, + }); + instance.render({ + fileDiff: externalDiff, + fileContainer, + renderRange: { + startingLine: 1, + totalLines: 2, + bufferBefore: 0, + bufferAfter: 0, + }, + }); + }, + ], + [ + 'an option-change rerender', + ({ instance }: Awaited>) => { + instance.setOptions({ + ...instance.options, + diffStyle: 'unified', + }); + instance.rerender(); + }, + ], + ] as const) { + test(`${triggerName} renders from the private session`, async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + try { + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ); + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff).not.toBe(externalDiff); + + triggerRender(fixture); + + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); + expect(instance.getRendererDiffForTest()).toBe(sessionDiff); + expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); + } + + test('a same-key external object renders from the private session', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, fileContainer, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + const equivalentExternalDiff = structuredClone(externalDiff); + const equivalentBefore = structuredClone(equivalentExternalDiff); + try { + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + + instance.render({ + fileDiff: equivalentExternalDiff, + fileContainer, + forceRender: true, + }); + + expect(instance.fileDiff).toBe(externalDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); + expect(instance.getRendererDiffForTest()).toBe(sessionDiff); + + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ); + + expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); + expect(externalDiff).toEqual(externalBefore.value); + expect(equivalentExternalDiff).toEqual(equivalentBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); + + test('new annotations render over the private session contents', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, fileContainer, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + try { + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ); + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + + instance.render({ + fileDiff: externalDiff, + fileContainer, + lineAnnotations: [{ side: 'additions', lineNumber: 2 }], + }); + + expect(instance.getRendererDiffForTest()).toBe(sessionDiff); + expect(fileContainer.shadowRoot?.textContent).toContain('edited value'); + expect( + fileContainer.shadowRoot?.querySelector( + 'slot[name="annotation-additions-2"]' + ) + ).not.toBeNull(); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); + + test('recycling clears renderer state without writing edits to the external diff', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + const externalBefore = captureExternalDiffState(externalDiff); + try { + instance.updateRenderCache( + makeDirtyLines([[1, 'edited value']]), + 'light' + ); + const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); + + detach(true); + instance.cleanUp(true); + + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); + expect(instance.getRendererDiffForTest()).toBeUndefined(); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + fixture.cleanup(); + } + }); + + test('real editor changes emit an edited keyless file without changing the external diff', async () => { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const externalDiff = createExternalDiff(); + const externalBefore = captureExternalDiffState(externalDiff); + const changedFiles: FileContents[] = []; + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + }); + const editor = new Editor({ + onChange: (file) => changedFiles.push(file), + }); + + try { + instance.render({ + fileDiff: externalDiff, + fileContainer, + forceRender: true, + }); + editor.edit(instance); + + await waitFor(() => editor.getText() === 'alpha\nnew value\nomega\n', { + timeout: 4_000, + }); + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 9 }, + }, + newText: 'edited value', + }, + ]); + + expect(changedFiles).toHaveLength(1); + expect(changedFiles[0]).toEqual({ + name: 'session.ts', + contents: 'alpha\nedited value\nomega\n', + }); + expect(changedFiles[0]?.cacheKey).toBeUndefined(); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); + } finally { + editor.cleanUp(); + instance.cleanUp(); + dom.cleanup(); + } + }); +}); diff --git a/packages/diffs/test/FileDiff.headerSlots.test.ts b/packages/diffs/test/FileDiff.headerSlots.test.ts index 6211773ca..603d6782a 100644 --- a/packages/diffs/test/FileDiff.headerSlots.test.ts +++ b/packages/diffs/test/FileDiff.headerSlots.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { FileDiff, parseDiffFromFile } from '../src'; +import type { DiffsEditor, DiffsTextDocument } from '../src/types'; import { installDom, wait } from './domHarness'; const fileDiff = parseDiffFromFile( @@ -14,6 +15,27 @@ function createSlotContent(text: string): HTMLElement { return element; } +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + +function makeTextDocument(lines: string[]): DiffsTextDocument { + return { + lineCount: lines.length, + getText: () => lines.join(''), + getLineText: (lineNumber: number, includeLineBreak = false) => { + const line = lines[lineNumber] ?? ''; + return includeLineBreak ? line : line.replace(/\r?\n$/, ''); + }, + }; +} + async function waitForSlotText( container: HTMLElement, slot: string, @@ -31,7 +53,56 @@ async function waitForSlotText( ); } +async function waitForHeaderCount( + container: HTMLElement, + selector: string, + expected: string +): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if ( + container.shadowRoot?.querySelector(selector)?.textContent === expected + ) { + return; + } + await wait(10); + } + expect(container.shadowRoot?.querySelector(selector)?.textContent).toBe( + expected + ); +} + describe('FileDiff header slots', () => { + test('updates default header counts from the private session', async () => { + const { cleanup } = installDom(); + const externalDiff = parseDiffFromFile( + { name: 'session.txt', contents: 'old\n' }, + { name: 'session.txt', contents: 'new\n' } + ); + const externalAdditionLines = externalDiff.additionLines; + const fileContainer = document.createElement('div'); + const instance = new FileDiff({ + collapsed: true, + disableErrorHandling: true, + }); + let detach: (() => void) | undefined; + + try { + instance.render({ fileDiff: externalDiff, fileContainer }); + await waitForHeaderCount(fileContainer, '[data-additions-count]', '+1'); + + detach = instance.attachEditor(createEditorStub()); + instance.applyDocumentChange(makeTextDocument(['new\n', 'extra\n'])); + + await waitForHeaderCount(fileContainer, '[data-additions-count]', '+2'); + expect(externalDiff.additionLines).toBe(externalAdditionLines); + expect(externalDiff.additionLines).toEqual(['new\n']); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + test('renders, updates, and removes the filename suffix slot', async () => { const { cleanup } = installDom(); const fileContainer = document.createElement('div'); diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 66b934f50..9f23896c6 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -8,19 +8,34 @@ import { parsePatchFiles, } from '../src'; import type { + DiffsEditor, FileContents, FileDiffLoadedFiles, FileDiffMetadata, + HighlightedToken, + SyncRenderViewProps, } from '../src/types'; import type { WorkerPoolManager } from '../src/worker'; -import { installDom, wait } from './domHarness'; -import { assertDefined } from './testUtils'; +import { installDom, wait, waitFor } from './domHarness'; +import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); }); class TestFileDiff extends FileDiff { + initializeHighlighterForTest() { + return this.hunksRenderer.initializeHighlighter(); + } + + getLatestDiffForTest() { + return this.getLatestDiff(); + } + + getRendererDiffForTest() { + return this.hunksRenderer.diffCache; + } + getExpandedHunkForTest(index: number) { return this.hunksRenderer.getExpandedHunk(index); } @@ -44,18 +59,21 @@ class TestFileDiff extends FileDiff { } } -function createDeferred(): { - promise: Promise; - resolve(value: T): void; - reject(error: unknown): void; -} { - let resolve: (value: T) => void = () => {}; - let reject: (error: unknown) => void = () => {}; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; +function createEditorStub(cachedContents?: string): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __getCachedDocumentContents: () => cachedContents, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + +function makeDirtyLines( + edits: ReadonlyArray<[number, string]> +): Map { + return new Map(edits.map(([line, text]) => [line, [[0, '', text]]])); } function createPrimeWorkerManager(): { @@ -281,6 +299,325 @@ function expectOneSidedPartialDoesNotStartHydration({ } describe('FileDiff partial hydration', () => { + test('an active edit hydrates its external baseline before creating a keyless session', async () => { + const { cleanup } = installDom(); + const { oldFile, newFile, partial } = createPartialChange('session.ts'); + partial.cacheKey = 'external:partial-session'; + const deferred = createDeferred(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: (fileDiff) => { + expect(fileDiff).toBe(partial); + return deferred.promise; + }, + }); + let detach: (() => void) | undefined; + + try { + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + detach = instance.attachEditor(createEditorStub()); + + const partialSession = instance.getLatestDiffForTest(); + expect(partialSession).toBeDefined(); + // Partial inputs remain external-only until the complete files arrive; + // session ownership begins with the fully hydrated value. + expect(partialSession).toBe(partial); + + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + expect(loadPromise).toBeDefined(); + deferred.resolve({ oldFile, newFile }); + await loadPromise; + + const hydratedSession = instance.getLatestDiffForTest(); + expect(hydratedSession).toBeDefined(); + expect(hydratedSession).not.toBe(partial); + expect(instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(false); + expect(partial.cacheKey).toBe('external:partial-session:hydrated'); + expect(hydratedSession?.isPartial).toBe(false); + expect(hydratedSession?.cacheKey).toBeUndefined(); + expect(hydratedSession?.additionLines).toBe(partial.additionLines); + expect(hydratedSession?.deletionLines).toBe(partial.deletionLines); + expect(hydratedSession?.hunks).toBe(partial.hunks); + expect(hydratedSession?.additionLines.join('')).toBe(newFile.contents); + expect(hydratedSession?.deletionLines.join('')).toBe(oldFile.contents); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + + test('a full replacement creates an edit session while partial hydration is pending', async () => { + const { cleanup } = installDom(); + const { oldFile, newFile, partial } = createPartialChange('replaced.txt'); + partial.cacheKey = 'external:partial'; + const replacement = parseDiffFromFile( + { name: 'replaced.txt', contents: 'previous\n' }, + { name: 'replaced.txt', contents: 'replacement\n' } + ); + replacement.cacheKey = 'external:full'; + const deferred = createDeferred(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: () => deferred.promise, + }); + let detach: ((recycle?: boolean) => void) | undefined; + let loadPromise: Promise | undefined; + + try { + await instance.initializeHighlighterForTest(); + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + detach = instance.attachEditor(createEditorStub()); + loadPromise = instance.getPendingFileLoadPromiseForTest(); + assertDefined(loadPromise, 'expected partial hydration to be pending'); + + instance.render({ + fileDiff: replacement, + fileContainer, + forceRender: true, + }); + + const editSessionDiff = instance.getLatestDiffForTest(); + expect(instance.fileDiff).toBe(replacement); + expect(editSessionDiff).not.toBe(replacement); + expect(editSessionDiff?.cacheKey).toBeUndefined(); + expect(editSessionDiff?.additionLines).toBe(replacement.additionLines); + expect(() => + instance.updateRenderCache(makeDirtyLines([[0, 'edited']]), 'light') + ).not.toThrow(); + expect(editSessionDiff?.additionLines[0]).toBe('edited\n'); + expect(replacement.additionLines[0]).toBe('replacement\n'); + + deferred.resolve({ oldFile, newFile }); + await loadPromise; + expect(partial.isPartial).toBe(true); + expect(instance.fileDiff).toBe(replacement); + expect(instance.getLatestDiffForTest()).toBe(editSessionDiff); + } finally { + deferred.resolve({ oldFile, newFile }); + await loadPromise; + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + + test('a hydrated diff starts its private edit model from cached contents', async () => { + const { cleanup } = installDom(); + const { oldFile, newFile, partial } = createPartialChange('persisted.ts'); + partial.cacheKey = 'external:persisted-partial'; + const cachedContents = [ + 'local start\n', + 'keep 1\n', + 'new value\n', + 'keep 3\n', + 'keep 4\n', + ].join(''); + const deferred = createDeferred(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: () => deferred.promise, + }); + const syncedViews: SyncRenderViewProps[] = []; + const editor = createEditorStub(cachedContents); + editor.__syncRenderView = (props) => syncedViews.push(props); + let detach: (() => void) | undefined; + + try { + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + detach = instance.attachEditor(editor); + + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + expect(loadPromise).toBeDefined(); + deferred.resolve({ oldFile, newFile }); + await loadPromise; + + const restoredDiff = instance.getLatestDiffForTest(); + expect(restoredDiff).toBeDefined(); + expect(restoredDiff).not.toBe(partial); + expect(restoredDiff?.cacheKey).toBeUndefined(); + expect(restoredDiff?.additionLines.join('')).toBe(cachedContents); + expect(restoredDiff?.additionLines).not.toBe(partial.additionLines); + expect(restoredDiff?.deletionLines).toBe(partial.deletionLines); + expect(restoredDiff?.hunks).not.toBe(partial.hunks); + expect(restoredDiff?.editSessionDirty).toBe(true); + expect(partial.additionLines.join('')).toBe(newFile.contents); + expect(partial.deletionLines.join('')).toBe(oldFile.contents); + expect(partial.editSessionDirty).not.toBe(true); + await waitFor(() => + syncedViews.some( + (view) => 'fileDiff' in view && view.restoredDocument !== undefined + ) + ); + expect( + syncedViews.filter( + (view) => 'fileDiff' in view && view.restoredDocument !== undefined + ) + ).toEqual([ + expect.objectContaining({ + fileDiff: restoredDiff, + restoredDocument: newFile.contents, + }), + ]); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + + test('recycle reuses an in-flight edit-session hydration request', async () => { + const { cleanup } = installDom(); + const { oldFile, newFile, partial } = createPartialChange( + 'recycled-session.ts' + ); + partial.cacheKey = 'external:recycled-partial-session'; + const deferred = createDeferred(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + let loadCalls = 0; + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: (fileDiff) => { + loadCalls++; + expect(fileDiff).toBe(partial); + return deferred.promise; + }, + }); + let firstDetach: ((recycle?: boolean) => void) | undefined; + let secondDetach: (() => void) | undefined; + + try { + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + const firstEditor = createEditorStub(); + firstEditor.cleanUp = (recycle) => firstDetach?.(recycle); + firstDetach = instance.attachEditor(firstEditor); + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + expect(loadPromise).toBeDefined(); + expect(loadCalls).toBe(1); + + instance.cleanUp(true); + instance.virtualizedSetup(); + instance.rerender(); + secondDetach = instance.attachEditor(createEditorStub()); + + expect(loadCalls).toBe(1); + deferred.resolve({ oldFile, newFile }); + await loadPromise; + + const hydratedSession = instance.getLatestDiffForTest(); + expect(hydratedSession).toBeDefined(); + expect(hydratedSession).not.toBe(partial); + expect(instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(false); + expect(partial.cacheKey).toBe( + 'external:recycled-partial-session:hydrated' + ); + expect(hydratedSession?.isPartial).toBe(false); + expect(hydratedSession?.cacheKey).toBeUndefined(); + expect(hydratedSession?.additionLines).toBe(partial.additionLines); + expect(hydratedSession?.deletionLines).toBe(partial.deletionLines); + expect(hydratedSession?.hunks).toBe(partial.hunks); + expect(hydratedSession?.additionLines.join('')).toBe(newFile.contents); + expect(hydratedSession?.deletionLines.join('')).toBe(oldFile.contents); + expect(loadCalls).toBe(1); + } finally { + secondDetach?.(); + instance.cleanUp(); + cleanup(); + } + }); + + test('the first edit after pure-rename hydration separates the aliased file sides', async () => { + const { cleanup } = installDom(); + const { newFile, partial } = createPartialPureRename(); + partial.cacheKey = 'external:partial-rename'; + const deferred = createDeferred(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: (fileDiff) => { + expect(fileDiff).toBe(partial); + return deferred.promise; + }, + }); + let detach: (() => void) | undefined; + + try { + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + detach = instance.attachEditor(createEditorStub()); + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + expect(loadPromise).toBeDefined(); + deferred.resolve({ oldFile: null, newFile }); + await loadPromise; + + const hydratedSession = instance.getLatestDiffForTest(); + expect(hydratedSession).toBeDefined(); + if (hydratedSession == null) return; + expect(hydratedSession).not.toBe(partial); + expect(hydratedSession.isPartial).toBe(false); + expect(hydratedSession.cacheKey).toBeUndefined(); + expect(partial.isPartial).toBe(false); + expect(partial.cacheKey).toBe('external:partial-rename:hydrated'); + // hydratePartialDiff intentionally reuses one owned line array for both + // sides of a pure rename. The first addition-side write must break that + // alias instead of changing the read-only deletion side. + expect(hydratedSession.additionLines).toBe(hydratedSession.deletionLines); + expect(hydratedSession.additionLines).toBe(partial.additionLines); + expect(partial.additionLines).toBe(partial.deletionLines); + const hydratedBaseBefore = structuredClone(partial); + + instance.updateRenderCache(makeDirtyLines([[0, 'ALPHA']]), 'light'); + + expect(instance.getRendererDiffForTest()).toBe(hydratedSession); + expect(hydratedSession.additionLines).not.toBe( + hydratedSession.deletionLines + ); + expect(hydratedSession.deletionLines).toEqual(['alpha\n', 'beta\n']); + expect(hydratedSession.deletionLines).toBe(partial.deletionLines); + expect(instance.fileDiff).toBe(partial); + expect(partial.additionLines).toBe(partial.deletionLines); + expect(partial).toEqual(hydratedBaseBefore); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + test('expandHunk hydrates once and preserves expansion state changes made while loading', async () => { const { cleanup } = installDom(); let instance: TestFileDiff | undefined; diff --git a/packages/diffs/test/FileDiff.rerenderInputs.test.ts b/packages/diffs/test/FileDiff.rerenderInputs.test.ts index c07eac145..f88c15fbc 100644 --- a/packages/diffs/test/FileDiff.rerenderInputs.test.ts +++ b/packages/diffs/test/FileDiff.rerenderInputs.test.ts @@ -1,7 +1,11 @@ import { afterAll, expect, test } from 'bun:test'; import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; -import type { DiffsEditor, FileDiffMetadata } from '../src/types'; +import type { + DiffsEditor, + FileDiffMetadata, + HighlightedToken, +} from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { @@ -136,68 +140,39 @@ test('parsed unkeyed diffs with the same filename render fresh contents', async } }); -for (const targetChange of ['name', 'prevName', 'lang'] as const) { - test(`a dirty unkeyed session does not swallow a diff with a different ${targetChange}`, async () => { - const { cleanup } = installDom(); - const firstDiff = createDiff('same.ts', 'firstMarker'); - const secondDiff = createDiff('same.ts', 'secondMarker'); - if (targetChange === 'name') { - secondDiff.name = 'other.ts'; - } else if (targetChange === 'prevName') { - firstDiff.prevName = 'first-old.ts'; - secondDiff.prevName = 'second-old.ts'; - } else { - firstDiff.lang = 'typescript'; - secondDiff.lang = 'javascript'; - } - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const instance = new FileDiff({ disableFileHeader: true }); - - try { - instance.render({ fileDiff: firstDiff, fileContainer }); - await waitForStableRow(fileContainer); - const detach = instance.attachEditor(createEditorStub()); - firstDiff.editSessionDirty = true; - - instance.render({ - fileDiff: secondDiff, - fileContainer, - forceRender: true, - }); - await waitForStableRow(fileContainer); - - expect(instance.fileDiff).toBe(secondDiff); - detach(); - } finally { - instance.cleanUp(); - cleanup(); - } - }); -} - -test('a dirty unkeyed session remains authoritative for the same target', async () => { +test('a dirty unkeyed session remains authoritative when the same object is re-passed', async () => { const { cleanup } = installDom(); - const firstDiff = createDiff('same.ts', 'firstMarker'); - const secondDiff = createDiff('same.ts', 'secondMarker'); + const externalDiff = createDiff('same.ts', 'firstMarker'); + const externalAdditionLines = externalDiff.additionLines; const fileContainer = document.createElement('div'); document.body.appendChild(fileContainer); const instance = new FileDiff({ disableFileHeader: true }); try { - instance.render({ fileDiff: firstDiff, fileContainer }); + instance.render({ fileDiff: externalDiff, fileContainer }); await waitForStableRow(fileContainer); const detach = instance.attachEditor(createEditorStub()); - firstDiff.editSessionDirty = true; + instance.updateRenderCache( + new Map([ + [0, [[0, '', 'const editedMarker = 3;']]], + ]), + 'light' + ); instance.render({ - fileDiff: secondDiff, + fileDiff: externalDiff, fileContainer, forceRender: true, }); - await waitForStableRow(fileContainer); + await waitFor( + () => + fileContainer.shadowRoot?.textContent?.includes('editedMarker') === true + ); - expect(instance.fileDiff).toBe(firstDiff); + expect(instance.fileDiff).toBe(externalDiff); + expect(externalDiff.additionLines).toBe(externalAdditionLines); + expect(externalDiff.additionLines.join('')).toContain('firstMarker'); + expect(externalDiff.additionLines.join('')).not.toContain('editedMarker'); detach(); } finally { instance.cleanUp(); diff --git a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts index 09ca64d8b..2dbf85ce8 100644 --- a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts +++ b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts @@ -1,7 +1,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; -import type { DiffsTextDocument } from '../src/types'; +import type { DiffsEditor, DiffsTextDocument } from '../src/types'; import { installDom } from './domHarness'; const twoHunkFileLineCount = 140; @@ -45,6 +45,16 @@ function makeTextDocument(lines: string[]): DiffsTextDocument { }; } +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + async function waitForRenderedCode(container: HTMLElement): Promise { for (let attempt = 0; attempt < 50; attempt++) { if (container.shadowRoot?.querySelector('code') != null) { @@ -64,8 +74,9 @@ describe('FileDiff unified edit separators', () => { await disposeHighlighter(); }); - test('applyDocumentChange refreshes function hunk separators', async () => { + test('applyDocumentChange refreshes function hunk separators from the session diff', async () => { const { cleanup } = installDom(); + let detach: (() => void) | undefined; let instance: FileDiff | undefined; try { const fileDiff = createTwoHunkDiff(); @@ -88,13 +99,21 @@ describe('FileDiff unified edit separators', () => { deferManagers: true, }); await waitForRenderedCode(fileContainer); + detach = instance.attachEditor(createEditorStub()); - expect(countSeparatorSlots(fileContainer)).toBeGreaterThan(0); + const initialSeparatorCount = countSeparatorSlots(fileContainer); + expect(initialSeparatorCount).toBeGreaterThan(0); - instance.applyDocumentChange(makeTextDocument(fileDiff.deletionLines)); + const sessionLines = [...fileDiff.additionLines]; + sessionLines[69] = 'changed-70\n'; + instance.applyDocumentChange(makeTextDocument(sessionLines)); - expect(countSeparatorSlots(fileContainer)).toBe(0); + expect(countSeparatorSlots(fileContainer)).toBeGreaterThan( + initialSeparatorCount + ); + expect(fileDiff.additionLines[69]).toBe('70\n'); } finally { + detach?.(); instance?.cleanUp(); cleanup(); } diff --git a/packages/diffs/test/FileDiff.workerRendering.test.ts b/packages/diffs/test/FileDiff.workerRendering.test.ts new file mode 100644 index 000000000..e9d3edded --- /dev/null +++ b/packages/diffs/test/FileDiff.workerRendering.test.ts @@ -0,0 +1,177 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; + +import { FileDiff } from '../src/components/FileDiff'; +import { + disposeHighlighter, + getSharedHighlighter, +} from '../src/highlighter/shared_highlighter'; +import type { + DiffLineAnnotation, + DiffsHighlighter, + FileDiffMetadata, + RenderDiffOptions, + ThemedDiffResult, +} from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { renderDiffWithHighlighter } from '../src/utils/renderDiffWithHighlighter'; +import { installDom, waitFor } from './domHarness'; +import { createInitializedManager, withTimeout } from './workerPoolHarness'; + +let sharedHighlighter: DiffsHighlighter; + +beforeAll(async () => { + sharedHighlighter = await getSharedHighlighter({ + themes: ['pierre-dark'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); +}); + +afterAll(async () => { + await disposeHighlighter(); +}); + +class TestFileDiff extends FileDiff { + getRenderedDiffForTest(): FileDiffMetadata | undefined { + return this.getRenderedDiff(); + } + + completeHighlightForTest( + fileDiff: FileDiffMetadata, + result: ThemedDiffResult, + options: RenderDiffOptions + ): void { + this.hunksRenderer.onHighlightSuccess(fileDiff, result, options); + } +} + +function createDiff(cacheKey: string, contents: string): FileDiffMetadata { + return parseDiffFromFile( + { + name: 'annotations.ts', + contents: 'const before = 0;\nconst stable = 1;\n', + cacheKey: `${cacheKey}:old`, + }, + { + name: 'annotations.ts', + contents, + cacheKey: `${cacheKey}:new`, + } + ); +} + +function getAnnotationText( + container: HTMLElement, + slot: string +): string | undefined { + return container + .querySelector(`[slot="${slot}"]`) + ?.textContent?.trim(); +} + +test('applies replacement annotations while its diff is highlighted', async () => { + const dom = installDom(); + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const instance = new TestFileDiff( + { + disableErrorHandling: true, + disableFileHeader: true, + renderAnnotation: (annotation) => { + const element = document.createElement('span'); + element.textContent = annotation.metadata; + return element; + }, + theme: 'pierre-dark', + }, + manager + ); + const fileContainer = document.createElement('div'); + fileContainer.attachShadow({ mode: 'open' }); + const diffA = createDiff( + 'annotations:a', + 'const alpha = 1;\nconst stable = 1;\n' + ); + const diffB = createDiff( + 'annotations:b', + 'const before = 0;\nconst beta = 2;\n' + ); + const annotationsA: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 1, metadata: 'annotation:A' }, + ]; + const annotationsB: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 2, metadata: 'annotation:B' }, + ]; + + try { + instance.render({ + fileContainer, + fileDiff: diffA, + lineAnnotations: annotationsA, + }); + const requestA = await withTimeout(worker.waitForDiffRequest()); + worker.respond({ + type: 'success', + requestType: 'diff', + id: requestA.id, + result: renderDiffWithHighlighter( + diffA, + sharedHighlighter, + manager.getDiffRenderOptions() + ), + options: manager.getDiffRenderOptions(), + sentAt: Date.now(), + }); + await waitFor( + () => + instance.getRenderedDiffForTest() === diffA && + getAnnotationText(fileContainer, 'annotation-additions-1') === + 'annotation:A' + ); + expect(instance.getRenderedDiffForTest()).toBe(diffA); + expect(getAnnotationText(fileContainer, 'annotation-additions-1')).toBe( + 'annotation:A' + ); + + instance.render({ + fileContainer, + fileDiff: diffB, + lineAnnotations: annotationsB, + }); + expect(instance.getRenderedDiffForTest()).toBe(diffA); + expect( + getAnnotationText(fileContainer, 'annotation-additions-1') + ).toBeUndefined(); + expect(getAnnotationText(fileContainer, 'annotation-additions-2')).toBe( + 'annotation:B' + ); + + instance.completeHighlightForTest( + diffB, + renderDiffWithHighlighter( + diffB, + sharedHighlighter, + manager.getDiffRenderOptions() + ), + manager.getDiffRenderOptions() + ); + await waitFor( + () => + instance.getRenderedDiffForTest() === diffB && + getAnnotationText(fileContainer, 'annotation-additions-2') === + 'annotation:B' + ); + expect(instance.getRenderedDiffForTest()).toBe(diffB); + expect(getAnnotationText(fileContainer, 'annotation-additions-2')).toBe( + 'annotation:B' + ); + expect( + getAnnotationText(fileContainer, 'annotation-additions-1') + ).toBeUndefined(); + } finally { + instance.cleanUp(); + manager.terminate(); + dom.cleanup(); + } +}); diff --git a/packages/diffs/test/FileRenderer.test.ts b/packages/diffs/test/FileRenderer.test.ts index bbefdb088..2491e3e9f 100644 --- a/packages/diffs/test/FileRenderer.test.ts +++ b/packages/diffs/test/FileRenderer.test.ts @@ -16,6 +16,12 @@ type FileRendererCacheProbe = { }; }; +function createEditSessionFile(file: FileContents): FileContents { + const editSessionFile = { ...file }; + delete editSessionFile.cacheKey; + return editSessionFile; +} + afterAll(async () => { await disposeHighlighter(); }); @@ -41,8 +47,10 @@ describe('FileRenderer', () => { name: 'editable.txt', }; - await instance.asyncRender(file); - expect(instance.renderFile(file)?.rowCount).toBe(3); + const editSessionFile = createEditSessionFile(file); + instance.beginEditSession(editSessionFile); + await instance.asyncRender(editSessionFile); + expect(instance.renderFile(editSessionFile)?.rowCount).toBe(3); instance.applyDocumentChange( new TextDocument('inmemory://editable-file', 'alpha\ngamma') @@ -64,8 +72,10 @@ describe('FileRenderer', () => { name: 'editable.txt', }; - await instance.asyncRender(file); - instance.renderFile(file); + const editSessionFile = createEditSessionFile(file); + instance.beginEditSession(editSessionFile); + await instance.asyncRender(editSessionFile); + instance.renderFile(editSessionFile); instance.updateRenderCache(new Map([[3, [[0, '#ff0000', 'D']]]]), 'light'); const dirtyLines = new Map([ @@ -105,6 +115,35 @@ describe('FileRenderer', () => { ]); }); + test('reuses editor-compatible markup for a retained edit session', async () => { + const instance = new FileRenderer({ + theme: 'pierre-light', + useTokenTransformer: true, + }); + const editSessionFile = createEditSessionFile({ + cacheKey: 'external-file', + contents: 'const value = 1;\n', + name: 'editable.ts', + }); + + instance.beginEditSession(editSessionFile); + await instance.asyncRender(editSessionFile); + instance.renderFile(editSessionFile); + instance.endEditSession(); + + instance.renderFile(editSessionFile); + const cacheBeforeReattach = (instance as unknown as FileRendererCacheProbe) + .renderCache; + expect(cacheBeforeReattach?.result).toBeDefined(); + + instance.beginEditSession(editSessionFile); + + expect((instance as unknown as FileRendererCacheProbe).renderCache).toBe( + cacheBeforeReattach + ); + expect(instance.editorRenderReady()).toBe(true); + }); + test.each([ { change: 'adding', @@ -133,8 +172,10 @@ describe('FileRenderer', () => { name: 'terminal.ts', }; - await instance.asyncRender(file); - instance.renderFile(file); + const editSessionFile = createEditSessionFile(file); + instance.beginEditSession(editSessionFile); + await instance.asyncRender(editSessionFile); + instance.renderFile(editSessionFile); instance.updateRenderCache(dirtyLines, 'light', true); instance.applyDocumentChange( new TextDocument('inmemory://terminal-newline', nextText, 'typescript') @@ -153,17 +194,17 @@ describe('FileRenderer', () => { } ); - test('rebuilds an unkeyed file mutated in place', async () => { + test('renders a distinct unkeyed file with new contents', async () => { const instance = new FileRenderer(); - const file: FileContents = { + const firstFile: FileContents = { contents: 'alpha', name: 'mutable.ts', }; - await instance.asyncRender(file); - expect(instance.renderFile(file)?.rowCount).toBe(1); + await instance.asyncRender(firstFile); + expect(instance.renderFile(firstFile)?.rowCount).toBe(1); - file.contents = 'alpha\nbeta\ngamma'; - expect(instance.renderFile(file)?.rowCount).toBe(3); + const nextFile = { ...firstFile, contents: 'alpha\nbeta\ngamma' }; + expect(instance.renderFile(nextFile)?.rowCount).toBe(3); }); }); diff --git a/packages/diffs/test/VirtualizedFile.fileSwap.test.ts b/packages/diffs/test/VirtualizedFile.fileSwap.test.ts index 57b84b8c0..94fdddb92 100644 --- a/packages/diffs/test/VirtualizedFile.fileSwap.test.ts +++ b/packages/diffs/test/VirtualizedFile.fileSwap.test.ts @@ -101,7 +101,7 @@ test('prepareCodeViewItem drops measured heights when the file is replaced', asy // the first call latches internal state (currentCollapsed) whose initial // transition forces a reset that would mask the swap below. Its return // value is the estimate-based height for a fresh layout cache. - const estimatedHeight = instance.prepareCodeViewItem(wrappedFile, 0); + const estimatedHeight = instance.updateCodeViewLayout(wrappedFile, 0); expect(estimatedHeight).toBeGreaterThan(0); await wait(10); @@ -113,7 +113,7 @@ test('prepareCodeViewItem drops measured heights when the file is replaced', asy // Replace the file wholesale; same line count, so a correctly reset cache // reproduces the original estimate-based height. - const height = instance.prepareCodeViewItem( + const height = instance.updateCodeViewLayout( makeNamedFile('replaced.ts', 'other'), 0 ); diff --git a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 3af1ffd46..2aadac00a 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -4,29 +4,36 @@ import { createTwoFilesPatch } from 'diff'; import { disposeHighlighter, parseDiffFromFile, parsePatchFiles } from '../src'; import { VirtualizedFileDiff } from '../src/components/VirtualizedFileDiff'; import type { Virtualizer } from '../src/components/Virtualizer'; -import type { FileContents, FileDiffMetadata } from '../src/types'; +import type { DiffsEditor, FileContents, FileDiffMetadata } from '../src/types'; import { installDom, wait } from './domHarness'; -import { assertDefined } from './testUtils'; +import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); }); class TestVirtualizedFileDiff extends VirtualizedFileDiff { + getLatestDiffForTest() { + return this.getLatestDiff(); + } + getExpandedHunkForTest(index: number) { return this.hunksRenderer.getExpandedHunk(index); } + + getPendingFileLoadPromiseForTest() { + return this.pendingFiles?.promise; + } } -function createDeferred(): { - promise: Promise; - resolve(value: T): void; -} { - let resolve: (value: T) => void = () => {}; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - return { promise, resolve }; +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; } function createVirtualizer(visible = true): { @@ -172,7 +179,7 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); instance.expandHunk(0, 'down', 1); instance.expandHunk(0, 'up', 1); @@ -243,7 +250,7 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); instance.expandHunk(partial.hunks.length, 'up', 1); instance.expandHunk(partial.hunks.length, 'up', 1); @@ -294,9 +301,9 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); instance.expandHunk(0, 'down', 1); - instance.prepareCodeViewItem(nextDiff, 0); + instance.updateCodeViewLayout(nextDiff, 0); deferred.resolve({ oldFile, newFile }); await wait(10); @@ -359,9 +366,9 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(fullDiff, 0); + instance.updateCodeViewLayout(fullDiff, 0); - const height = instance.prepareCodeViewItem(partial, 0); + const height = instance.updateCodeViewLayout(partial, 0); expect(typeof height).toBe('number'); expect(instance.fileDiff).toBe(partial); @@ -382,10 +389,9 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(firstChange.partial, 0); + instance.updateCodeViewLayout(firstChange.partial, 0); instance.expandHunk(0, 'down', 1); - instance.prepareCodeViewItem(secondChange.partial, 0); - instance.consumeCodeViewLayoutChanges(secondChange.partial); + instance.updateCodeViewLayout(secondChange.partial, 0); expect(instance.getExpandedHunkForTest(0)).toEqual({ fromStart: 0, @@ -399,7 +405,7 @@ describe('VirtualizedFileDiff partial hydration', () => { } }); - test('stages a hydrated clone for CodeView until layout changes are consumed', async () => { + test('commits staged CodeView hydration at the layout-consumption boundary', async () => { let instance: TestVirtualizedFileDiff | undefined; try { const { oldFile, newFile, partial } = createPartialChange('partial.ts'); @@ -414,7 +420,7 @@ describe('VirtualizedFileDiff partial hydration', () => { virtualizerState.virtualizer ); - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); instance.expandHunk(0, 'down', 1); deferred.resolve(loadedContents); await wait(10); @@ -427,26 +433,181 @@ describe('VirtualizedFileDiff partial hydration', () => { { layoutDirty: true }, ]); - const nextDiff = instance.consumeCodeViewLayoutChanges(partial); + instance.updateCodeViewLayout(partial, 0); - assertDefined(nextDiff, 'expected next diff'); - expect(nextDiff).not.toBe(partial); - expect(nextDiff.isPartial).toBe(false); - expect(nextDiff.additionLines).toEqual([ + expect(instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(false); + expect(partial.additionLines).toEqual([ 'keep 1\n', 'new value\n', 'keep 3\n', 'keep 4\n', ]); expect(instance.fileDiff).toBe(partial); - instance.prepareCodeViewItem(nextDiff, 0); - expect(instance.fileDiff).toBe(nextDiff); - expect(partial.isPartial).toBe(true); } finally { instance?.cleanUp(); } }); + test('advanced edit hydration survives recycling before layout consumption', async () => { + const { oldFile, newFile, partial } = createPartialChange('advanced.ts'); + partial.cacheKey = 'external:advanced-partial'; + const deferred = createDeferred<{ + oldFile: FileContents; + newFile: FileContents; + }>(); + const virtualizerState = createAdvancedVirtualizer(); + const instance = new TestVirtualizedFileDiff( + { + disableFileHeader: true, + loadDiffFiles: () => deferred.promise, + }, + virtualizerState.virtualizer + ); + let detach: (() => void) | undefined; + + try { + instance.updateCodeViewLayout(partial, 0); + const editor = createEditorStub(); + detach = instance.attachEditor(editor); + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + assertDefined(loadPromise, 'expected edit hydration to be pending'); + + deferred.resolve({ oldFile, newFile }); + await loadPromise; + + expect(partial.isPartial).toBe(true); + expect(instance.getLatestDiffForTest()).toBe(partial); + + instance.cleanUp(true); + detach = undefined; + instance.updateCodeViewLayout(partial, 0); + + expect(instance.getLatestDiffForTest()).toBe(partial); + expect(partial.isPartial).toBe(false); + + instance.virtualizedSetup(); + instance.updateCodeViewLayout(partial, 0); + detach = instance.attachEditor(editor); + + const sessionDiff = instance.getLatestDiffForTest(); + expect(instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(false); + expect(partial.cacheKey).toBe('external:advanced-partial:hydrated'); + expect(sessionDiff).not.toBe(partial); + expect(sessionDiff?.cacheKey).toBeUndefined(); + expect(sessionDiff?.additionLines).toBe(partial.additionLines); + expect(sessionDiff?.deletionLines).toBe(partial.deletionLines); + expect(sessionDiff?.hunks).toBe(partial.hunks); + } finally { + detach?.(); + instance.cleanUp(); + } + }); + + test('advanced replacement hydration keeps the previous session until layout consumes it', async () => { + const initial = parseDiffFromFile( + { + name: 'replacement.ts', + contents: 'keep 1\nold value\nkeep 3\nkeep 4\n', + }, + { + name: 'replacement.ts', + contents: 'keep 1\nfirst value\nkeep 3\nkeep 4\n', + } + ); + initial.cacheKey = 'external:replacement-v1'; + const { oldFile, newFile, partial } = createPartialChange('replacement.ts'); + partial.cacheKey = 'external:replacement-partial'; + const deferred = createDeferred<{ + oldFile: FileContents; + newFile: FileContents; + }>(); + const virtualizerState = createAdvancedVirtualizer(); + const instance = new TestVirtualizedFileDiff( + { + disableFileHeader: true, + loadDiffFiles: () => deferred.promise, + }, + virtualizerState.virtualizer + ); + let detach: (() => void) | undefined; + + try { + instance.updateCodeViewLayout(initial, 0); + detach = instance.attachEditor(createEditorStub()); + const previousSession = instance.getLatestDiffForTest(); + expect(previousSession).not.toBe(initial); + + instance.updateCodeViewLayout(partial, 0); + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + assertDefined( + loadPromise, + 'expected replacement hydration to be pending' + ); + expect(instance.fileDiff).toBe(partial); + expect(instance.getLatestDiffForTest()).toBe(previousSession); + + deferred.resolve({ oldFile, newFile }); + await loadPromise; + expect(partial.isPartial).toBe(true); + expect(instance.getLatestDiffForTest()).toBe(previousSession); + + instance.updateCodeViewLayout(partial, 0); + const nextSession = instance.getLatestDiffForTest(); + expect(partial.isPartial).toBe(false); + expect(nextSession).not.toBe(previousSession); + expect(nextSession).not.toBe(partial); + expect(nextSession?.cacheKey).toBeUndefined(); + expect(nextSession?.additionLines).toBe(partial.additionLines); + expect(nextSession?.deletionLines).toBe(partial.deletionLines); + } finally { + detach?.(); + instance.cleanUp(); + } + }); + + test('simple edit hydration creates its session from the hydrated base', async () => { + const { oldFile, newFile, partial } = createPartialChange('simple.ts'); + partial.cacheKey = 'external:simple-partial'; + const deferred = createDeferred<{ + oldFile: FileContents; + newFile: FileContents; + }>(); + const virtualizerState = createVirtualizer(); + const instance = new TestVirtualizedFileDiff( + { + disableFileHeader: true, + loadDiffFiles: () => deferred.promise, + }, + virtualizerState.virtualizer + ); + let detach: (() => void) | undefined; + + try { + instance.updateCodeViewLayout(partial, 0); + detach = instance.attachEditor(createEditorStub()); + const loadPromise = instance.getPendingFileLoadPromiseForTest(); + assertDefined(loadPromise, 'expected edit hydration to be pending'); + + deferred.resolve({ oldFile, newFile }); + await loadPromise; + + const sessionDiff = instance.getLatestDiffForTest(); + expect(instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(false); + expect(partial.cacheKey).toBe('external:simple-partial:hydrated'); + expect(sessionDiff).not.toBe(partial); + expect(sessionDiff?.cacheKey).toBeUndefined(); + expect(sessionDiff?.additionLines).toBe(partial.additionLines); + expect(sessionDiff?.deletionLines).toBe(partial.deletionLines); + expect(sessionDiff?.hunks).toBe(partial.hunks); + } finally { + detach?.(); + instance.cleanUp(); + } + }); + test('expandUnchanged starts hydration for pure rename partial diffs', async () => { const { cleanup } = installDom(); let instance: TestVirtualizedFileDiff | undefined; @@ -491,4 +652,43 @@ describe('VirtualizedFileDiff partial hydration', () => { cleanup(); } }); + + test('same-key wrappers preserve the original edit-session baseline', () => { + const { cleanup } = installDom(); + const externalDiff = parseDiffFromFile( + { name: 'same-key.txt', contents: 'old\n' }, + { name: 'same-key.txt', contents: 'new\n' } + ); + externalDiff.cacheKey = 'external:same-key'; + const equivalentDiff = structuredClone(externalDiff); + const fileContainer = document.createElement('div'); + const virtualizerState = createVirtualizer(false); + const instance = new TestVirtualizedFileDiff( + { disableFileHeader: true }, + virtualizerState.virtualizer + ); + let detach: (() => void) | undefined; + + try { + instance.render({ fileContainer, fileDiff: externalDiff }); + detach = instance.attachEditor(createEditorStub()); + const sessionDiff = instance.getLatestDiffForTest(); + + instance.updateCodeViewLayout(equivalentDiff, 0); + instance.render({ + fileContainer, + fileDiff: equivalentDiff, + forceRender: true, + }); + + expect(instance.fileDiff).toBe(externalDiff); + expect(sessionDiff).not.toBe(externalDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); + expect(sessionDiff?.additionLines).toBe(externalDiff.additionLines); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); }); diff --git a/packages/diffs/test/VirtualizedFileDiff.setVisibility.test.ts b/packages/diffs/test/VirtualizedFileDiff.setVisibility.test.ts new file mode 100644 index 000000000..7572d2731 --- /dev/null +++ b/packages/diffs/test/VirtualizedFileDiff.setVisibility.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from 'bun:test'; + +import { VirtualizedFileDiff } from '../src/components/VirtualizedFileDiff'; +import type { Virtualizer } from '../src/components/Virtualizer'; +import type { FileDiffMetadata } from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { installDom } from './domHarness'; +import { createInitializedManager } from './workerPoolHarness'; + +class TestVirtualizedFileDiff extends VirtualizedFileDiff { + getRenderedDiffForTest(): FileDiffMetadata | undefined { + return this.getRenderedDiff(); + } +} + +function createDiff(cacheKey: string, contents: string): FileDiffMetadata { + return parseDiffFromFile( + { + name: 'visibility.txt', + contents: 'before\n', + cacheKey: `${cacheKey}:old`, + }, + { + name: 'visibility.txt', + contents: `${contents}\n`, + cacheKey: `${cacheKey}:new`, + } + ); +} + +function createVirtualizer(isVisible: boolean): Virtualizer { + return { + config: { resizeDebugging: false }, + type: 'simple', + connect() {}, + disconnect() {}, + getOffsetInScrollContainer() { + return 0; + }, + getWindowSpecs() { + return { top: 0, bottom: 1000 }; + }, + instanceChanged() {}, + isInstanceVisible() { + return isVisible; + }, + markDOMDirty() {}, + requestHeightReconcile() {}, + } as unknown as Virtualizer; +} + +test('renders a replacement after an off-screen placeholder', async () => { + const dom = installDom(); + const { manager } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const container = document.createElement('diffs-container'); + const instance = new TestVirtualizedFileDiff( + {}, + createVirtualizer(false), + undefined, + manager, + true + ); + const firstDiff = createDiff('first', 'first value'); + const replacementDiff = createDiff('replacement', 'replacement value'); + + try { + instance.render({ fileDiff: firstDiff, fileContainer: container }); + expect( + container.shadowRoot?.querySelectorAll('[data-placeholder]').length + ).toBe(1); + expect(instance.getRenderedDiffForTest()).toBeUndefined(); + + instance.render({ + fileDiff: replacementDiff, + fileContainer: container, + }); + expect( + container.shadowRoot?.querySelectorAll('[data-placeholder]').length + ).toBe(0); + expect(instance.getRenderedDiffForTest()).toBe(replacementDiff); + } finally { + instance.cleanUp(); + manager.terminate(); + dom.cleanup(); + } +}); diff --git a/packages/diffs/test/advancedStickySpecs.test.ts b/packages/diffs/test/advancedStickySpecs.test.ts index 14d890a7c..e3387aedf 100644 --- a/packages/diffs/test/advancedStickySpecs.test.ts +++ b/packages/diffs/test/advancedStickySpecs.test.ts @@ -90,7 +90,7 @@ function leadingWindow(height: number) { describe('VirtualizedFileDiff.getAdvancedStickySpecs', () => { test('reports a fully rendered item at its top with its full height', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeDiff(), ITEM_TOP); + instance.updateCodeViewLayout(makeDiff(), ITEM_TOP); const height = instance.getVirtualizedHeight(); expect( @@ -103,7 +103,7 @@ describe('VirtualizedFileDiff.getAdvancedStickySpecs', () => { test('anchors a trailing header-only item at its top (no bufferAfter)', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeDiff(), ITEM_TOP); + instance.updateCodeViewLayout(makeDiff(), ITEM_TOP); expect(instance.getAdvancedStickySpecs(trailingWindow())).toEqual({ topOffset: ITEM_TOP, @@ -113,7 +113,7 @@ describe('VirtualizedFileDiff.getAdvancedStickySpecs', () => { test('anchors a leading header-only item at its bottom (offset by bufferAfter)', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeDiff(), ITEM_TOP); + instance.updateCodeViewLayout(makeDiff(), ITEM_TOP); const height = instance.getVirtualizedHeight(); const bufferAfter = height - HEADER_ONLY_HEIGHT; @@ -133,7 +133,7 @@ describe('VirtualizedFileDiff.getAdvancedStickySpecs', () => { virtualizer, metrics ); - instance.prepareCodeViewItem(makeDiff(), ITEM_TOP); + instance.updateCodeViewLayout(makeDiff(), ITEM_TOP); const height = instance.getVirtualizedHeight(); expect(instance.getAdvancedStickySpecs(trailingWindow())).toEqual({ @@ -146,7 +146,7 @@ describe('VirtualizedFileDiff.getAdvancedStickySpecs', () => { describe('VirtualizedFile.getAdvancedStickySpecs', () => { test('reports a fully rendered item at its top with its full height', () => { const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeFile(), ITEM_TOP); + instance.updateCodeViewLayout(makeFile(), ITEM_TOP); const height = instance.getVirtualizedHeight(); expect( @@ -159,7 +159,7 @@ describe('VirtualizedFile.getAdvancedStickySpecs', () => { test('anchors a trailing header-only item at its top (no bufferAfter)', () => { const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeFile(), ITEM_TOP); + instance.updateCodeViewLayout(makeFile(), ITEM_TOP); expect(instance.getAdvancedStickySpecs(trailingWindow())).toEqual({ topOffset: ITEM_TOP, @@ -169,7 +169,7 @@ describe('VirtualizedFile.getAdvancedStickySpecs', () => { test('anchors a leading header-only item at its bottom (offset by bufferAfter)', () => { const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(makeFile(), ITEM_TOP); + instance.updateCodeViewLayout(makeFile(), ITEM_TOP); const height = instance.getVirtualizedHeight(); const bufferAfter = height - HEADER_ONLY_HEIGHT; @@ -187,7 +187,7 @@ describe('VirtualizedFile.getAdvancedStickySpecs', () => { virtualizer, metrics ); - instance.prepareCodeViewItem(makeFile(), ITEM_TOP); + instance.updateCodeViewLayout(makeFile(), ITEM_TOP); const height = instance.getVirtualizedHeight(); expect(instance.getAdvancedStickySpecs(trailingWindow())).toEqual({ diff --git a/packages/diffs/test/e2e/edit.pw.ts b/packages/diffs/test/e2e/edit.pw.ts index d55963802..91328f238 100644 --- a/packages/diffs/test/e2e/edit.pw.ts +++ b/packages/diffs/test/e2e/edit.pw.ts @@ -5,9 +5,12 @@ const DELETIONS = '[data-code][data-deletions] [data-content]'; async function openFixture( page: Page, - options: { gutterUtility?: boolean } = {} + options: { annotations?: boolean; gutterUtility?: boolean } = {} ): Promise { - const query = options.gutterUtility === true ? '?gutterUtility' : ''; + const searchParams = new URLSearchParams(); + if (options.annotations === true) searchParams.set('annotations', ''); + if (options.gutterUtility === true) searchParams.set('gutterUtility', ''); + const query = searchParams.size === 0 ? '' : `?${searchParams}`; await page.goto(`/test/e2e/fixtures/edit.html${query}`); await page.waitForFunction(() => window.__editReady === true); } @@ -186,6 +189,35 @@ test.describe('edit mode', () => { await expect.poll(() => canUndo(page)).toBe(true); }); + test('inserting a line keeps split annotations aligned', async ({ page }) => { + await openFixture(page, { annotations: true }); + + const annotationRows = page.locator('[data-line-annotation]'); + await expect(annotationRows).toHaveCount(2); + const getAnnotationTops = () => + annotationRows.evaluateAll((rows) => + rows.map((row) => row.getBoundingClientRect().top) + ); + const before = await getAnnotationTops(); + expect(Math.abs(before[0] - before[1])).toBeLessThan(1); + + const annotatedLine = page.locator( + `${ADDITIONS} [data-line="2"][data-line-type="change-addition"]` + ); + await annotatedLine.click(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await expect.poll(() => changeCount(page)).toBeGreaterThan(0); + + await expect(annotationRows).toHaveCount(2); + await expect + .poll(async () => { + const [deletionTop, additionTop] = await getAnnotationTops(); + return Math.abs(deletionTop - additionTop); + }) + .toBeLessThan(1); + }); + test('focused editor survives a full render and accepts input', async ({ page, }) => { diff --git a/packages/diffs/test/e2e/fixtures/edit.html b/packages/diffs/test/e2e/fixtures/edit.html index 9dd389cad..c688990a3 100644 --- a/packages/diffs/test/e2e/fixtures/edit.html +++ b/packages/diffs/test/e2e/fixtures/edit.html @@ -57,21 +57,42 @@ throw new Error('Missing edit fixture nodes.'); } - const oldFile = { - name: 'edit.ts', - contents: `const value = 1; + const withAnnotations = new URLSearchParams(location.search).has( + 'annotations' + ); + const oldFile = withAnnotations + ? { + name: 'edit.ts', + contents: `const shared = 1; +export default shared; +`, + } + : { + name: 'edit.ts', + contents: `const value = 1; const removed = 'old'; export default value; `, - }; + }; - const newFile = { - name: 'edit.ts', - contents: `const value = 1; + const newFile = withAnnotations + ? { + name: 'edit.ts', + contents: `const shared = 1; +const annotated = 2; +export default shared; +`, + } + : { + name: 'edit.ts', + contents: `const value = 1; const added = 'new'; export default value; `, - }; + }; + const lineAnnotations = withAnnotations + ? [{ lineNumber: 2, side: 'additions' }] + : undefined; // Every onChange payload is recorded so tests can assert the editor emits // the updated file contents rather than inspecting the DOM alone. @@ -95,9 +116,21 @@ 'gutterUtility' ), onGutterUtilityClick() {}, + renderAnnotation() { + const annotation = document.createElement('div'); + annotation.dataset.testAnnotation = ''; + annotation.style.height = '80px'; + annotation.textContent = 'annotation'; + return annotation; + }, }); - instance.render({ oldFile, newFile, containerWrapper: mount }); + instance.render({ + oldFile, + newFile, + lineAnnotations, + containerWrapper: mount, + }); editor.edit(instance); // Counts completed editor re-syncs. A full render keeps the editable // element in place, so specs can't detect completion by watching for a diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index a3ee9943f..58e8d057a 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -192,57 +192,14 @@ async function renderFileAndWait( } describe('Editor persisted file state', () => { - test('requires an explicit cache key when enabled', () => { - const dom = installDom(); - const fileContainer = document.createElement('div'); - const fileContents: FileContents = { - name: 'unkeyed.ts', - contents: 'alpha\n', - }; - const file = new File({ - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const editor = new Editor({ persistState: true }); - - try { - file.render({ file: fileContents, fileContainer, forceRender: true }); - - expect(() => editor.edit(file)).toThrow( - 'Editor persistState requires a non-empty file.cacheKey for "unkeyed.ts".' - ); - expect(fileContents.cacheKey).toBeUndefined(); - } finally { - editor.cleanUp(); - file.cleanUp(); - dom.cleanup(); - } - }); - - test('rejects enabling persistence before an attached file finishes syncing', () => { - const dom = installDom(); - const fileContainer = document.createElement('div'); - const file = new File({ - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const editor = new Editor(); - + test('rejects enabling persistence for an attached unkeyed document', async () => { + const fixture = await createEditorFixture('alpha\n'); try { - file.render({ - file: { name: 'edits.ts', contents: 'alpha\n' }, - fileContainer, - forceRender: true, - }); - editor.edit(file); - - expect(() => editor.setOptions({ persistState: true })).toThrow( + expect(() => fixture.editor.setOptions({ persistState: true })).toThrow( 'Editor persistState requires a non-empty file.cacheKey for "edits.ts".' ); } finally { - editor.cleanUp(); - file.cleanUp(); - dom.cleanup(); + fixture.cleanup(); } }); @@ -282,55 +239,6 @@ describe('Editor persisted file state', () => { } }); - test('restores the cached document, undo history, and editor state', async () => { - const fixture = await createEditorFixture('alpha\nbravo\n', { - persistState: true, - }); - - try { - insertAtStart(fixture.editor, 'X'); - fixture.editor.setSelections([ - { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - direction: 'forward', - }, - ]); - - await renderFileAndWait(fixture, { - name: 'other.ts', - contents: 'one\n', - cacheKey: 'other', - }); - // A fresh object with the same explicit key resumes the editing session. - await renderFileAndWait(fixture, { - name: 'edits.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'edits-file', - }); - - expect(fixture.editor.getText()).toBe('Xalpha\nbravo\n'); - expect(fixture.editor.getState().selections).toEqual([ - { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - direction: 1, - }, - ]); - expect( - fixture.fileContainer.shadowRoot?.querySelector( - '[data-content] [data-line="1"]' - )?.textContent - ).toBe('Xalpha'); - expect(fixture.editor.canUndo).toBe(true); - - fixture.editor.undo(); - expect(fixture.editor.getText()).toBe('alpha\nbravo\n'); - } finally { - fixture.cleanup(); - } - }); - test('uses a custom state storage with the explicit file key', async () => { const states = new Map['getState']>>(); const calls: string[] = []; @@ -369,7 +277,6 @@ describe('Editor persisted file state', () => { }); expect(calls).toContain('set:edits-file'); - expect(calls).toContain('get:other-revision'); expect(calls).toContain('set:other-revision'); expect(calls.at(-1)).toBe('get:edits-file'); expect(fixture.editor.getState().selections?.[0]).toMatchObject({ @@ -447,7 +354,7 @@ describe('Editor.applyEdits selection sync', () => { } }); - test('keeps inserted file lines coherent when switching files', async () => { + test('switching files does not write inserted lines into the external file', async () => { const { cleanup, editor, file, fileContainer, fileContents } = await createEditorFixture('alpha\nbravo\n', undefined, { disableErrorHandling: true, @@ -475,11 +382,11 @@ describe('Editor.applyEdits selection sync', () => { file.render({ file: otherFile, fileContainer, forceRender: true }) ).not.toThrow(); - expect(fileContents.contents).toBe('alpha\nbravo\ncharlie\n'); + expect(fileContents.contents).toBe('alpha\nbravo\n'); expect(() => file.render({ file: fileContents, fileContainer, forceRender: true }) ).not.toThrow(); - expect(editor.getText()).toBe('alpha\nbravo\ncharlie\n'); + await waitFor(() => editor.getText() === 'alpha\nbravo\n'); } finally { cleanup(); } diff --git a/packages/diffs/test/editorClipboard.test.ts b/packages/diffs/test/editorClipboard.test.ts index 6d41ffd3c..53b50aed7 100644 --- a/packages/diffs/test/editorClipboard.test.ts +++ b/packages/diffs/test/editorClipboard.test.ts @@ -205,13 +205,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - this.#lineAnnotations, - this.#renderRange - ); + this.#editor?.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: this.fileContainer, + file: this.#file, + externalCacheKey: this.#file.cacheKey, + lineAnnotations: this.#lineAnnotations, + renderRange: this.#renderRange, + }); } #renderShadowDom(): void { diff --git a/packages/diffs/test/editorDiffEmptyDocument.test.ts b/packages/diffs/test/editorDiffEmptyDocument.test.ts index c2e78f3e8..ea04b868e 100644 --- a/packages/diffs/test/editorDiffEmptyDocument.test.ts +++ b/packages/diffs/test/editorDiffEmptyDocument.test.ts @@ -4,7 +4,7 @@ import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor } from '../src/editor/editor'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { FileContents } from '../src/types'; +import type { FileContents, FileDiffMetadata } from '../src/types'; import { installDom, wait } from './domHarness'; afterAll(async () => { @@ -53,6 +53,13 @@ function countEditableLineEls(content: HTMLElement): number { return count; } +function getEditSessionDiff( + fileDiff: FileDiff +): FileDiffMetadata | undefined { + return (fileDiff as unknown as { editSessionDiff?: FileDiffMetadata }) + .editSessionDiff; +} + interface DiffEditorFixture { container: HTMLElement; editor: Editor; @@ -163,7 +170,7 @@ describe('diff editor: empty document', () => { } }); - test(`restores the zero-line diff after an attach-only session (${diffStyle})`, async () => { + test(`keeps the external zero-line diff unchanged after an attach-only session (${diffStyle})`, async () => { const fixture = await createDiffEditorFixture( diffStyle, 'removed 1\nremoved 2\n', @@ -171,21 +178,22 @@ describe('diff editor: empty document', () => { ); try { - expect(fixture.fileDiff.fileDiff?.additionLines).toEqual(['']); + expect(fixture.fileDiff.fileDiff?.additionLines).toEqual([]); + expect(getEditSessionDiff(fixture.fileDiff)?.additionLines).toEqual([ + '', + ]); fixture.editor.cleanUp(); for (let attempt = 0; attempt < 40; attempt++) { const content = findAdditionContent(fixture.container); - if ( - fixture.fileDiff.fileDiff?.additionLines.length === 0 && - (content == null || countEditableLineEls(content) === 0) - ) { + if (content == null || countEditableLineEls(content) === 0) { break; } await wait(0); } expect(fixture.fileDiff.fileDiff?.additionLines).toEqual([]); + expect(getEditSessionDiff(fixture.fileDiff)?.additionLines).toEqual([]); const content = findAdditionContent(fixture.container); expect(content == null ? 0 : countEditableLineEls(content)).toBe(0); } finally { diff --git a/packages/diffs/test/editorDisplayOptionResync.test.ts b/packages/diffs/test/editorDisplayOptionResync.test.ts index af75de67f..15841d105 100644 --- a/packages/diffs/test/editorDisplayOptionResync.test.ts +++ b/packages/diffs/test/editorDisplayOptionResync.test.ts @@ -356,12 +356,9 @@ describe('diff editor: display-option toggle mid-edit', () => { } }); - // Exercises the fileDiff-prop path the React bridge uses: the host holds one - // diff object. A line-count edit followed by a forced re-render that re-passes - // a fresh diff object resets the rendered rows to the original count, so the - // re-render must come from the document - otherwise inserted lines are never - // created (and deleted lines never removed). - test('keeps an inserted line when the host re-passes a fresh diff object', async () => { + // Exercises the fileDiff-prop path the React bridge uses when an options + // update re-passes the same controlled input during an active edit session. + test('keeps an inserted line when the host re-passes the same diff', async () => { const dom = installDom(); const container = document.createElement('div'); document.body.appendChild(container); @@ -374,12 +371,13 @@ describe('diff editor: display-option toggle mid-edit', () => { const oldContents = 'alpha\nbravo\n'; const newContents = 'alpha\nCHANGED\n'; const file = { name: 'edit.ts' }; + const externalDiff = parseDiffFromFile( + { ...file, contents: oldContents }, + { ...file, contents: newContents } + ); fileDiff.render({ - fileDiff: parseDiffFromFile( - { ...file, contents: oldContents }, - { ...file, contents: newContents } - ), + fileDiff: externalDiff, fileContainer: container, forceRender: true, }); @@ -400,14 +398,11 @@ describe('diff editor: display-option toggle mid-edit', () => { await wait(0); expect(lineText(container, 2)).toBe('INSERTED'); - // Forced re-render with a brand-new diff object (as a host that re-derives - // its fileDiff each render would pass), which resets the rendered rows. + // A display-option update re-passes the same controlled diff. The private + // edit session remains the render source for its inserted line. fileDiff.setOptions({ ...fileDiff.options, disableLineNumbers: true }); fileDiff.render({ - fileDiff: parseDiffFromFile( - { ...file, contents: oldContents }, - { ...file, contents: newContents } - ), + fileDiff: externalDiff, fileContainer: container, forceRender: true, }); diff --git a/packages/diffs/test/editorFoldNavigation.test.ts b/packages/diffs/test/editorFoldNavigation.test.ts index 97ec32c74..c9f6ee1a2 100644 --- a/packages/diffs/test/editorFoldNavigation.test.ts +++ b/packages/diffs/test/editorFoldNavigation.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_THEMES } from '../src/constants'; import { Editor } from '../src/editor/editor'; import { isMoveCursorShortcut } from '../src/editor/platform'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { FileDiffMetadata } from '../src/types'; import { installDom, wait } from './domHarness'; afterAll(async () => { @@ -30,6 +31,13 @@ function findAdditionContent(container: HTMLElement): HTMLElement | undefined { return undefined; } +function getEditSessionDiff( + fileDiff: FileDiff +): FileDiffMetadata | undefined { + return (fileDiff as unknown as { editSessionDiff?: FileDiffMetadata }) + .editSessionDiff; +} + interface FoldFixture { container: HTMLElement; editor: Editor; @@ -306,7 +314,10 @@ describe('diff editor: reveal-on-jump', () => { const fixture = await createFoldFixture(); const { container, editor, fileDiff } = fixture; try { - const hunksBefore = fileDiff.fileDiff!.hunks.length; + const externalHunks = fileDiff.fileDiff!.hunks; + const sessionDiff = getEditSessionDiff(fileDiff); + expect(sessionDiff).toBeDefined(); + const hunksBefore = sessionDiff!.hunks.length; // Mirrors search replaceAll: a buffer edit with no active selection // into a line hidden inside the collapsed gap. editor.applyEdits( @@ -324,7 +335,9 @@ describe('diff editor: reveal-on-jump', () => { // The deferred escalation re-render runs through the rAF queue. await wait(30); - expect(fileDiff.fileDiff!.hunks.length).toBe(hunksBefore + 1); + expect(getEditSessionDiff(fileDiff)).toBe(sessionDiff); + expect(sessionDiff!.hunks.length).toBe(hunksBefore + 1); + expect(fileDiff.fileDiff!.hunks).toBe(externalHunks); const content = findAdditionContent(container); const row = content?.querySelector('[data-line="30"]'); expect(row).not.toBeNull(); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 15a6c0da5..86bb9a3d7 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -5,8 +5,15 @@ import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type IStateStorage } from '../src/edit'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { EditorState, FileContents } from '../src/types'; +import type { + EditorChange, + EditorState, + FileContents, + FileDiffMetadata, +} from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; import { installDom, wait, waitFor } from './domHarness'; +import { createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); @@ -23,17 +30,10 @@ interface AttachedFile { file: File; } -interface Deferred { - promise: Promise; - resolve(value: T): void; -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; +class TestFileDiff extends FileDiff { + getLatestDiffForTest(): FileDiffMetadata | undefined { + return this.getLatestDiff(); + } } async function attachFile( @@ -366,6 +366,70 @@ describe('Editor persisted state lifecycle', () => { } }); + test('cached document contents require the same persisted identity', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + const editorWithoutPersistence = new Editor(); + let attached: AttachedFile | undefined; + + try { + attached = await attachFile(editor, { ...ORIGINAL_FILE }); + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'X', + }, + ]); + editor.cleanUp(); + + expect( + editor.__getCachedDocumentContents({ + name: ORIGINAL_FILE.name, + cacheKey: ORIGINAL_FILE.cacheKey, + }) + ).toBe('Xalpha\nbravo\n'); + expect( + editor.__getCachedDocumentContents({ + name: ORIGINAL_FILE.name, + cacheKey: ORIGINAL_FILE.cacheKey, + }) + ).toBe('Xalpha\nbravo\n'); + expect( + editor.__getCachedDocumentContents({ + name: ORIGINAL_FILE.name, + cacheKey: 'another-document', + }) + ).toBeUndefined(); + expect( + editor.__getCachedDocumentContents({ + name: 'renamed.ts', + cacheKey: ORIGINAL_FILE.cacheKey, + }) + ).toBeUndefined(); + expect( + editor.__getCachedDocumentContents({ + name: ORIGINAL_FILE.name, + lang: 'css', + cacheKey: ORIGINAL_FILE.cacheKey, + }) + ).toBeUndefined(); + expect( + editorWithoutPersistence.__getCachedDocumentContents({ + name: ORIGINAL_FILE.name, + cacheKey: ORIGINAL_FILE.cacheKey, + }) + ).toBeUndefined(); + } finally { + editor.cleanUp(); + editorWithoutPersistence.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + test('a rename with the same cache key keeps text, state, and history', async () => { const dom = installDom(); const editor = new Editor({ persistState: true }); @@ -649,10 +713,9 @@ describe('Editor persisted state lifecycle', () => { } }); - // Diffs persist only their serializable state, keyed by the cacheKey - // parseDiffFromFile derives from the file pair. A first attach has no - // record (reset to 0,0); a later fresh editor + fresh FileDiff for the same - // pair restores the persisted viewport position. + // Diff documents, history, and serializable editor state are stored under + // the cacheKey derived from the file pair. A first attach has no state record + // and resets to 0,0; a later FileDiff for the same pair restores its viewport. test('with persistState, a diff resets on first attach and restores on revisit', async () => { const dom = installDom(); const editor = new Editor({ persistState: true }); @@ -719,14 +782,23 @@ describe('Editor persisted state lifecycle', () => { } }); - // A FileDiff renders straight from the host's metadata (no __prepareFile - // substitution like File), so a host that re-parses pristine metadata after - // edits — same derived cacheKey, original content — must get a document - // built from that metadata, not the edited cached one, or the rendered rows - // and the editing document would diverge. - test('a re-parsed pristine diff does not adopt the edited cached document', async () => { + test('a fresh FileDiff restores cached text into its private edit model', async () => { const dom = installDom(); - const editor = new Editor({ persistState: true }); + const changes: Array<{ + cacheKey: string | undefined; + changes: EditorChange[]; + contents: string; + }> = []; + const editor = new Editor({ + persistState: true, + onChange(file, _lineAnnotations, event) { + changes.push({ + cacheKey: file.cacheKey, + changes: event.changes, + contents: file.contents, + }); + }, + }); const container = document.createElement('div'); document.body.appendChild(container); const oldFile: FileContents = { @@ -739,20 +811,24 @@ describe('Editor persisted state lifecycle', () => { contents: 'alpha\nbravo\n', cacheKey: 'diffed-reparse:new', }; - const first = new FileDiff({ + const externalDiff = parseDiffFromFile(oldFile, newFile); + const externalBefore = structuredClone(externalDiff); + const externalAdditionLines = externalDiff.additionLines; + const externalDeletionLines = externalDiff.deletionLines; + const externalHunks = externalDiff.hunks; + const first = new TestFileDiff({ disableErrorHandling: true, disableFileHeader: true, theme: DEFAULT_THEMES, }); - const second = new FileDiff({ + const second = new TestFileDiff({ disableErrorHandling: true, disableFileHeader: true, theme: DEFAULT_THEMES, }); try { first.render({ - oldFile, - newFile, + fileDiff: externalDiff, fileContainer: container, forceRender: true, }); @@ -768,20 +844,247 @@ describe('Editor persisted state lifecycle', () => { }, ]); expect(editor.getText()).toBe('edited alpha\nbravo\n'); + expect(changes.map((change) => change.contents)).toEqual([ + 'edited alpha\nbravo\n', + ]); editor.cleanUp(); first.cleanUp(); container.innerHTML = ''; + changes.length = 0; second.render({ - oldFile, - newFile, + fileDiff: externalDiff, fileContainer: container, forceRender: true, }); editor.edit(second); - // waitFor times out silently; the expect below is the real assertion. - await waitFor(() => editor.getText() === 'alpha\nbravo\n'); + await waitFor( + () => + editor.getText() === 'edited alpha\nbravo\n' && + container.shadowRoot?.querySelector('[data-content] [data-line="1"]') + ?.textContent === 'edited alpha' && + changes.length === 1 + ); + + const restoredDiff = second.getLatestDiffForTest(); + expect(restoredDiff).toBeDefined(); + expect(restoredDiff).not.toBe(externalDiff); + expect(restoredDiff?.cacheKey).toBeUndefined(); + expect(restoredDiff?.additionLines.join('')).toBe( + 'edited alpha\nbravo\n' + ); + expect(restoredDiff?.additionLines).not.toBe(externalAdditionLines); + expect(restoredDiff?.deletionLines).toBe(externalDeletionLines); + expect(restoredDiff?.hunks).not.toBe(externalHunks); + expect(externalDiff.additionLines).toBe(externalAdditionLines); + expect(externalDiff.deletionLines).toBe(externalDeletionLines); + expect(externalDiff.hunks).toBe(externalHunks); + expect(externalDiff).toEqual(externalBefore); + expect(editor.canUndo).toBe(true); + expect(editor.canRedo).toBe(false); + expect(changes).toEqual([ + { + cacheKey: undefined, + changes: [ + { + start: 0, + end: 'alpha\nbravo\n'.length, + text: 'edited alpha\nbravo\n', + range: { + start: { line: 0, character: 0 }, + end: { line: 2, character: 0 }, + }, + }, + ], + contents: 'edited alpha\nbravo\n', + }, + ]); + + editor.undo(); expect(editor.getText()).toBe('alpha\nbravo\n'); + expect(editor.canUndo).toBe(false); + expect(editor.canRedo).toBe(true); + editor.redo(); + expect(editor.getText()).toBe('edited alpha\nbravo\n'); + expect(changes.map((change) => change.contents)).toEqual([ + 'edited alpha\nbravo\n', + 'alpha\nbravo\n', + 'edited alpha\nbravo\n', + ]); + expect(externalDiff).toEqual(externalBefore); + } finally { + editor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('restoring an identical cached FileDiff does not emit a change', async () => { + const dom = installDom(); + const changes: string[] = []; + const editor = new Editor({ + persistState: true, + onChange: (file) => changes.push(file.contents), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const externalDiff = parseDiffFromFile( + { + name: 'identical.ts', + contents: 'before\n', + cacheKey: 'identical:old', + }, + { + name: 'identical.ts', + contents: 'after\n', + cacheKey: 'identical:new', + } + ); + const first = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const second = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + first.render({ + fileDiff: externalDiff, + fileContainer: container, + forceRender: true, + }); + editor.edit(first); + await waitFor(() => editor.getText() === 'after\n'); + editor.cleanUp(); + first.cleanUp(); + container.innerHTML = ''; + + second.render({ + fileDiff: externalDiff, + fileContainer: container, + forceRender: true, + }); + editor.edit(second); + await waitFor( + () => + editor.getText() === 'after\n' && + container.shadowRoot + ?.querySelector('[data-content]') + ?.getAttribute('contenteditable') === 'true' + ); + + expect(changes).toEqual([]); + } finally { + editor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('a restored FileDiff accepts a later external update through normal history', async () => { + const dom = installDom(); + const changes: string[] = []; + const editor = new Editor({ + persistState: true, + onChange: (file) => changes.push(file.contents), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const oldFile: FileContents = { + name: 'restored-update.ts', + contents: 'base\n', + }; + const initialDiff = parseDiffFromFile(oldFile, { + name: oldFile.name, + contents: 'alpha\n', + }); + initialDiff.cacheKey = 'restored-update:v1'; + const replacementDiff = parseDiffFromFile(oldFile, { + name: oldFile.name, + contents: 'charlie\n', + }); + replacementDiff.cacheKey = 'restored-update:v2'; + const initialBefore = structuredClone(initialDiff); + const replacementBefore = structuredClone(replacementDiff); + const first = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const second = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + + try { + first.render({ + fileDiff: initialDiff, + fileContainer: container, + forceRender: true, + }); + editor.edit(first); + await waitFor(() => editor.getText() === 'alpha\n'); + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'local ', + }, + ]); + expect(editor.getText()).toBe('local alpha\n'); + + editor.cleanUp(); + first.cleanUp(); + container.innerHTML = ''; + changes.length = 0; + + second.render({ + fileDiff: initialDiff, + fileContainer: container, + forceRender: true, + }); + editor.edit(second); + await waitFor( + () => editor.getText() === 'local alpha\n' && changes.length === 1 + ); + + second.render({ + fileDiff: replacementDiff, + fileContainer: container, + forceRender: true, + }); + await waitFor( + () => editor.getText() === 'charlie\n' && changes.length === 2 + ); + + expect(changes).toEqual(['local alpha\n', 'charlie\n']); + editor.undo(); + expect(editor.getText()).toBe('local alpha\n'); + editor.undo(); + expect(editor.getText()).toBe('alpha\n'); + editor.redo(); + expect(editor.getText()).toBe('local alpha\n'); + editor.redo(); + expect(editor.getText()).toBe('charlie\n'); + expect(changes).toEqual([ + 'local alpha\n', + 'charlie\n', + 'local alpha\n', + 'alpha\n', + 'local alpha\n', + 'charlie\n', + ]); + expect(initialDiff).toEqual(initialBefore); + expect(replacementDiff).toEqual(replacementBefore); } finally { editor.cleanUp(); first.cleanUp(); diff --git a/packages/diffs/test/editorRecycle.test.ts b/packages/diffs/test/editorRecycle.test.ts index 371bc40ad..2517f95d6 100644 --- a/packages/diffs/test/editorRecycle.test.ts +++ b/packages/diffs/test/editorRecycle.test.ts @@ -144,13 +144,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - this.#lineAnnotations, - this.#renderRange - ); + this.#editor?.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: this.fileContainer, + file: this.#file, + externalCacheKey: this.#file.cacheKey, + lineAnnotations: this.#lineAnnotations, + renderRange: this.#renderRange, + }); } #renderShadowDom(): void { @@ -268,13 +269,15 @@ describe('Editor onAttach lifecycle', () => { await wait(0); expect(onAttach).not.toHaveBeenCalled(); - editor.__syncRenderView( - createTestHighlighter(), - component.fileContainer, - createFile(), - undefined, - undefined - ); + const file = createFile(); + editor.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: component.fileContainer, + file, + externalCacheKey: file.cacheKey, + lineAnnotations: undefined, + renderRange: undefined, + }); await wait(0); expect(onAttach).not.toHaveBeenCalled(); diff --git a/packages/diffs/test/editorRenderUpdateLifecycle.test.ts b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts new file mode 100644 index 000000000..d1ebd9c5e --- /dev/null +++ b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts @@ -0,0 +1,477 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { createTwoFilesPatch } from 'diff'; + +import { + disposeHighlighter, + File, + FileDiff, + parseDiffFromFile, + parsePatchFiles, +} from '../src'; +import { Editor } from '../src/editor/editor'; +import type { + FileContents, + FileDiffLoadedFiles, + FileDiffMetadata, + SupportedLanguages, +} from '../src/types'; +import { installDom, waitFor } from './domHarness'; +import { assertDefined, createDeferred } from './testUtils'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +class TestFileDiff extends FileDiff { + getSessionDiff(): FileDiffMetadata | undefined { + return this.getLatestDiff(); + } +} + +function createDiff({ + cacheKey, + name = 'session.ts', + oldContents = 'base\n', + newContents, + lang, + type, +}: { + cacheKey?: string; + name?: string; + oldContents?: string; + newContents: string; + lang?: SupportedLanguages; + type?: FileDiffMetadata['type']; +}): FileDiffMetadata { + const diff = parseDiffFromFile( + { name, contents: oldContents }, + { name, contents: newContents } + ); + diff.cacheKey = cacheKey; + diff.lang = lang; + diff.type = type ?? diff.type; + return diff; +} + +async function createFixture(options?: { + initialCacheKey?: string | null; + initialOldContents?: string; + initialType?: FileDiffMetadata['type']; + loadDiffFiles?: (fileDiff: FileDiffMetadata) => Promise; + onChange?: (contents: string) => void; + persistState?: boolean; +}) { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const initialDiff = createDiff({ + cacheKey: + options?.initialCacheKey === null + ? undefined + : (options?.initialCacheKey ?? 'session:v1'), + oldContents: options?.initialOldContents ?? 'base\n', + newContents: 'alpha\n', + type: options?.initialType, + }); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + loadDiffFiles: options?.loadDiffFiles, + }); + const editor = new Editor({ + persistState: options?.persistState, + onChange: (file) => options?.onChange?.(file.contents), + }); + + instance.render({ + fileDiff: initialDiff, + fileContainer, + forceRender: true, + }); + editor.edit(instance); + await waitFor(() => editor.getText() === 'alpha\n', { timeout: 4_000 }); + + return { + dom, + editor, + fileContainer, + initialDiff, + instance, + cleanup() { + editor.cleanUp(); + instance.cleanUp(); + dom.cleanup(); + }, + }; +} + +function createPartialDiff( + oldContents: string, + newContents: string +): FileDiffMetadata { + const patch = createTwoFilesPatch( + 'session.ts', + 'session.ts', + oldContents, + newContents + ); + const diff = parsePatchFiles(patch, 'partial', true)[0]?.files[0]; + assertDefined(diff, 'expected a parsed partial diff'); + diff.cacheKey = 'session:partial-v2'; + return diff; +} + +function replaceDocument(editor: Editor, contents: string): void { + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { + line: Number.MAX_SAFE_INTEGER, + character: Number.MAX_SAFE_INTEGER, + }, + }, + newText: contents, + }, + ]); +} + +async function renderReplacement( + fixture: Awaited>, + replacement: FileDiffMetadata, + contents: string +): Promise { + fixture.instance.render({ + fileDiff: replacement, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getText() === contents, { + timeout: 4_000, + }); +} + +describe('external FileDiff updates during editing', () => { + test('a compatible update becomes one undoable edit and emits its contents', async () => { + const changes: string[] = []; + const fixture = await createFixture({ + onChange: (contents) => changes.push(contents), + }); + const replacement = createDiff({ + cacheKey: 'session:v2', + newContents: 'charlie\n', + }); + const replacementBefore = structuredClone(replacement); + const replacementAdditionLines = replacement.additionLines; + const replacementHunks = replacement.hunks; + + try { + replaceDocument(fixture.editor, 'bravo\n'); + expect(changes).toEqual(['bravo\n']); + + await renderReplacement(fixture, replacement, 'charlie\n'); + + const sessionDiff = fixture.instance.getSessionDiff(); + expect(fixture.instance.fileDiff).toBe(replacement); + expect(sessionDiff).not.toBe(replacement); + expect(sessionDiff?.cacheKey).toBeUndefined(); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + fixture.editor.redo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.redo(); + expect(fixture.editor.getText()).toBe('charlie\n'); + expect(changes).toEqual([ + 'bravo\n', + 'charlie\n', + 'bravo\n', + 'alpha\n', + 'bravo\n', + 'charlie\n', + ]); + expect(replacement).toEqual(replacementBefore); + expect(replacement.additionLines).toBe(replacementAdditionLines); + expect(replacement.hunks).toBe(replacementHunks); + } finally { + fixture.cleanup(); + } + }); + + test('an identical update changes external identity without adding history', async () => { + const changes: string[] = []; + const fixture = await createFixture({ + onChange: (contents) => changes.push(contents), + }); + const replacement = createDiff({ + cacheKey: 'session:v2', + newContents: 'bravo\n', + }); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + await renderReplacement(fixture, replacement, 'bravo\n'); + expect(changes).toEqual(['bravo\n']); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + expect(fixture.editor.canUndo).toBe(false); + expect(changes).toEqual(['bravo\n', 'alpha\n']); + } finally { + fixture.cleanup(); + } + }); + + test('a distinct unkeyed update follows the same compatible history path', async () => { + const fixture = await createFixture({ initialCacheKey: null }); + const replacement = createDiff({ newContents: 'charlie\n' }); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + await renderReplacement(fixture, replacement, 'charlie\n'); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + } finally { + fixture.cleanup(); + } + }); + + test('a compatible partial update waits for hydration before joining history', async () => { + const loaded = createDeferred(); + const changes: string[] = []; + const fixture = await createFixture({ + loadDiffFiles: () => loaded.promise, + onChange: (contents) => changes.push(contents), + }); + const partial = createPartialDiff('base\n', 'charlie\n'); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + fixture.instance.render({ + fileDiff: partial, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + + expect(fixture.instance.fileDiff).toBe(partial); + expect(partial.isPartial).toBe(true); + expect(fixture.editor.getText()).toBe('bravo\n'); + + loaded.resolve({ + oldFile: { name: 'session.ts', contents: 'base\n' }, + newFile: { name: 'session.ts', contents: 'charlie\n' }, + }); + await waitFor( + () => !partial.isPartial && fixture.editor.getText() === 'charlie\n', + { timeout: 4_000 } + ); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + expect(changes).toEqual(['bravo\n', 'charlie\n', 'bravo\n', 'alpha\n']); + } finally { + fixture.cleanup(); + } + }); + + test('an incompatible partial update resets history after hydration', async () => { + const loaded = createDeferred(); + const changes: string[] = []; + const fixture = await createFixture({ + loadDiffFiles: () => loaded.promise, + onChange: (contents) => changes.push(contents), + }); + const partial = createPartialDiff('different base\n', 'charlie\n'); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + fixture.instance.render({ + fileDiff: partial, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + expect(fixture.editor.getText()).toBe('bravo\n'); + + loaded.resolve({ + oldFile: { name: 'session.ts', contents: 'different base\n' }, + newFile: { name: 'session.ts', contents: 'charlie\n' }, + }); + await waitFor( + () => !partial.isPartial && fixture.editor.getText() === 'charlie\n', + { timeout: 4_000 } + ); + + expect(fixture.editor.canUndo).toBe(false); + expect(fixture.editor.canRedo).toBe(false); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + } finally { + fixture.cleanup(); + } + }); + + for (const [name, initialType, replacementType] of [ + ['a new diff becomes a change with an empty old side', 'new', 'change'], + ['a change with an empty old side becomes a new diff', 'change', 'new'], + ] as const) { + test(`${name} and starts fresh history`, async () => { + const changes: string[] = []; + const fixture = await createFixture({ + initialOldContents: '', + initialType, + onChange: (contents) => changes.push(contents), + }); + const replacement = createDiff({ + cacheKey: 'session:v2', + oldContents: '', + newContents: 'charlie\n', + type: replacementType, + }); + + try { + expect(fixture.initialDiff.deletionLines).toEqual([]); + expect(replacement.deletionLines).toEqual([]); + expect(fixture.initialDiff.type).toBe(initialType); + expect(replacement.type).toBe(replacementType); + + replaceDocument(fixture.editor, 'bravo\n'); + expect(fixture.instance.getSessionDiff()?.type).toBe( + fixture.initialDiff.type + ); + await renderReplacement(fixture, replacement, 'charlie\n'); + + expect(fixture.editor.canUndo).toBe(false); + expect(fixture.editor.canRedo).toBe(false); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + } finally { + fixture.cleanup(); + } + }); + } + + test('two new-file diff updates retain compatible history', async () => { + const fixture = await createFixture({ + initialOldContents: '', + initialType: 'new', + }); + const replacement = createDiff({ + cacheKey: 'session:v2', + oldContents: '', + newContents: 'charlie\n', + type: 'new', + }); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + await renderReplacement(fixture, replacement, 'charlie\n'); + + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('bravo\n'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + } finally { + fixture.cleanup(); + } + }); + + for (const [name, replacement] of [ + [ + 'file name changes', + createDiff({ + cacheKey: 'session:renamed', + name: 'renamed.ts', + newContents: 'charlie\n', + }), + ], + [ + 'effective language changes', + createDiff({ + cacheKey: 'session:javascript', + lang: 'javascript', + newContents: 'charlie\n', + }), + ], + [ + 'the old file changes', + createDiff({ + cacheKey: 'session:new-base', + oldContents: 'different base\n', + newContents: 'charlie\n', + }), + ], + ] as const) { + test(`${name} start fresh history`, async () => { + const changes: string[] = []; + const fixture = await createFixture({ + onChange: (contents) => changes.push(contents), + }); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + await renderReplacement(fixture, replacement, 'charlie\n'); + + expect(fixture.editor.canUndo).toBe(false); + expect(fixture.editor.canRedo).toBe(false); + expect(changes).toEqual(['bravo\n', 'charlie\n']); + } finally { + fixture.cleanup(); + } + }); + } + + test('a cache-key transition keeps independent persisted documents', async () => { + const fixture = await createFixture({ persistState: true }); + const replacement = createDiff({ + cacheKey: 'session:v2', + newContents: 'charlie\n', + }); + let restoredFile: File | undefined; + const restoredContainer = document.createElement('div'); + document.body.appendChild(restoredContainer); + + try { + replaceDocument(fixture.editor, 'bravo\n'); + await renderReplacement(fixture, replacement, 'charlie\n'); + replaceDocument(fixture.editor, 'delta\n'); + + fixture.editor.cleanUp(); + fixture.instance.cleanUp(); + restoredFile = new File({ + disableErrorHandling: true, + disableFileHeader: true, + }); + const originalFile: FileContents = { + name: 'session.ts', + contents: 'alpha\n', + cacheKey: 'session:v1', + }; + restoredFile.render({ + file: originalFile, + fileContainer: restoredContainer, + forceRender: true, + }); + fixture.editor.edit(restoredFile); + await waitFor(() => fixture.editor.getText() === 'bravo\n', { + timeout: 4_000, + }); + + expect(fixture.editor.getText()).toBe('bravo\n'); + expect(fixture.editor.getText()).not.toBe('delta\n'); + expect(fixture.editor.canUndo).toBe(true); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe('alpha\n'); + } finally { + fixture.editor.cleanUp(); + restoredFile?.cleanUp(); + fixture.dom.cleanup(); + } + }); +}); diff --git a/packages/diffs/test/editorState.test.ts b/packages/diffs/test/editorState.test.ts index fdb1a3977..ad8d20669 100644 --- a/packages/diffs/test/editorState.test.ts +++ b/packages/diffs/test/editorState.test.ts @@ -112,13 +112,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.file, - this.#lineAnnotations, - this.#renderRange - ); + this.#editor?.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: this.fileContainer, + file: this.file, + externalCacheKey: this.file.cacheKey, + lineAnnotations: this.#lineAnnotations, + renderRange: this.#renderRange, + }); } #renderShadowDom(): void { diff --git a/packages/diffs/test/editorVirtualizedReveal.test.ts b/packages/diffs/test/editorVirtualizedReveal.test.ts index 693e0e293..701e96a01 100644 --- a/packages/diffs/test/editorVirtualizedReveal.test.ts +++ b/packages/diffs/test/editorVirtualizedReveal.test.ts @@ -104,19 +104,20 @@ class VirtualizedEditableComponent implements DiffsEditableComponent ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - undefined, - { + this.#editor?.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: this.fileContainer, + file: this.#file, + externalCacheKey: this.#file.cacheKey, + lineAnnotations: undefined, + renderRange: { // Render only line 3 so the selected line 2 remains virtualized. startingLine: 2, totalLines: 1, bufferBefore: 0, bufferAfter: 0, - } - ); + }, + }); } #renderShadowDom(): void { diff --git a/packages/diffs/test/editorWorkerPool.test.ts b/packages/diffs/test/editorWorkerPool.test.ts index 2d6d45090..d6eb32919 100644 --- a/packages/diffs/test/editorWorkerPool.test.ts +++ b/packages/diffs/test/editorWorkerPool.test.ts @@ -11,18 +11,31 @@ import { disposeHighlighter, getSharedHighlighter, } from '../src/highlighter/shared_highlighter'; -import { DiffHunksRenderer } from '../src/renderers/DiffHunksRenderer'; +import { + DiffHunksRenderer, + type HunksRenderResult, +} from '../src/renderers/DiffHunksRenderer'; import { FileRenderer } from '../src/renderers/FileRenderer'; -import type { DiffsEditor, FileContents } from '../src/types'; +import type { + DiffsEditor, + DiffsHighlighter, + FileContents, + FileDiffMetadata, + HighlightedToken, +} from '../src/types'; import { getDiffHunksRendererOptions } from '../src/utils/getDiffHunksRendererOptions'; import { renderDiffWithHighlighter } from '../src/utils/renderDiffWithHighlighter'; import { renderFileWithHighlighter } from '../src/utils/renderFileWithHighlighter'; +import type { RenderDiffRequest } from '../src/worker/types'; +import type { WorkerPoolManager } from '../src/worker/WorkerPoolManager'; import { installDom, wait } from './domHarness'; +import { createDeferred, type Deferred } from './testUtils'; import { createInitializedManager, installAnimationFramePolyfill, respondToDiffRequest, respondToFileRequest, + type TestWorker, withTimeout, } from './workerPoolHarness'; @@ -34,8 +47,33 @@ import { let restoreAnimationFrame: (() => void) | undefined; -beforeAll(() => { +function createKeylessSessionDiff( + externalDiff: FileDiffMetadata +): FileDiffMetadata { + const sessionDiff = { ...externalDiff }; + delete sessionDiff.cacheKey; + return sessionDiff; +} + +class DeferredHighlighterDiffRenderer extends DiffHunksRenderer { + readonly initializations: Deferred[] = []; + + override initializeHighlighter(): Promise { + const deferred = createDeferred(); + this.initializations.push(deferred); + return deferred.promise; + } +} + +let sharedHighlighter: DiffsHighlighter; + +beforeAll(async () => { restoreAnimationFrame = installAnimationFramePolyfill(); + sharedHighlighter = await getSharedHighlighter({ + themes: ['pierre-dark'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); }); afterAll(async () => { @@ -49,6 +87,12 @@ function createFile(cacheKey: string): FileContents { return { name: 'demo.ts', contents: FILE_CONTENTS, cacheKey }; } +function createEditSessionFile(file: FileContents): FileContents { + const editSessionFile = { ...file }; + delete editSessionFile.cacheKey; + return editSessionFile; +} + // A structurally valid plain (non-transformer) worker result for `contents`: // one line element per line, the shape processFileResult requires. function plainFileCode(contents: string): ElementContent[] { @@ -64,6 +108,70 @@ function plainFileCode(contents: string): ElementContent[] { })); } +function renderedDiffHtml( + result: ReturnType +): string { + return toHtml([ + ...(result?.unifiedContentAST ?? []), + ...(result?.additionsContentAST ?? []), + ...(result?.deletionsContentAST ?? []), + ]); +} + +function createWorkerDiff( + cacheKeyPrefix: string, + contents: string, + name = 'pending.ts' +): FileDiffMetadata { + return parseDiffFromFile( + { + name, + contents: name.endsWith('.txt') ? 'before\n' : 'const before = 0;\n', + cacheKey: `${cacheKeyPrefix}:old`, + }, + { + name, + contents, + cacheKey: `${cacheKeyPrefix}:new`, + } + ); +} + +function respondWithHighlightedDiff( + manager: WorkerPoolManager, + worker: TestWorker, + request: RenderDiffRequest, + diff: FileDiffMetadata +): void { + const options = manager.getDiffRenderOptions(); + worker.respond({ + type: 'success', + requestType: 'diff', + id: request.id, + result: renderDiffWithHighlighter(diff, sharedHighlighter, options), + options, + sentAt: Date.now(), + }); +} + +async function renderHighlightedDiff( + renderer: DiffHunksRenderer, + manager: WorkerPoolManager, + worker: TestWorker, + diff: FileDiffMetadata +): Promise { + renderer.renderDiff(diff); + const request = await withTimeout(worker.waitForDiffRequest()); + respondWithHighlightedDiff(manager, worker, request, diff); + await waitFor(() => expect(manager.getDiffResultCache(diff)).toBeDefined()); + + const result = renderer.renderDiff(diff); + if (result == null) { + throw new Error('Expected the highlighted diff to render'); + } + return result; +} + // Budget stays below bun's 5s test timeout so a failing poll rejects (and // the test's finally-cleanup runs) before bun abandons the test — a zombie // cleanup firing mid-way through a later test tears down its DOM globals. @@ -86,7 +194,7 @@ async function waitFor( } describe('FileRenderer edit session', () => { - test('a session render skips the pool and produces token markup', async () => { + test('editing renders locally with editor-compatible token markup', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -103,11 +211,12 @@ describe('FileRenderer edit session', () => { await withTimeout(worker.waitForFileRequest()); expect(worker.fileRequestCount).toBe(1); - renderer.beginEditSession(); - renderer.renderFile(file); + const editSessionFile = createEditSessionFile(file); + renderer.beginEditSession(editSessionFile, file); + renderer.renderFile(editSessionFile); await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); - const result = renderer.renderFile(file); + const result = renderer.renderFile(editSessionFile); if (result == null) { throw new Error('expected a render result'); } @@ -120,7 +229,7 @@ describe('FileRenderer edit session', () => { } }); - test('a pool result that lands after attach is dropped', async () => { + test('ignores a worker result that finishes after editing begins', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -136,7 +245,9 @@ describe('FileRenderer edit session', () => { renderer.renderFile(file); const request = await withTimeout(worker.waitForFileRequest()); - renderer.beginEditSession(); + const editSessionFile = createEditSessionFile(file); + renderer.beginEditSession(editSessionFile, file); + renderer.renderFile(editSessionFile); const poolMarker: ElementContent[] = [ { type: 'element', @@ -148,10 +259,9 @@ describe('FileRenderer edit session', () => { respondToFileRequest(manager, worker, request, poolMarker); // Refused outright: nothing is applied and nothing is requested — the // session render issued at editor attach supplies the highlight. - await wait(50); - expect(renderUpdates).toBe(0); + await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); - const result = renderer.renderFile(file); + const result = renderer.renderFile(editSessionFile); if (result == null) { throw new Error('expected a render result'); } @@ -161,7 +271,7 @@ describe('FileRenderer edit session', () => { } }); - test('a pool with the transformer enabled cannot bypass the session through its cache', async () => { + test('ignores a late worker result even when its render options match the editor', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', useTokenTransformer: true, @@ -178,7 +288,9 @@ describe('FileRenderer edit session', () => { renderer.renderFile(file); const request = await withTimeout(worker.waitForFileRequest()); - renderer.beginEditSession(); + const editSessionFile = createEditSessionFile(file); + renderer.beginEditSession(editSessionFile, file); + renderer.renderFile(editSessionFile); const poolMarker: ElementContent[] = [ { type: 'element', @@ -191,19 +303,18 @@ describe('FileRenderer edit session', () => { // session options — the refused result must not sneak back in through // the manager's result cache on the next session render. respondToFileRequest(manager, worker, request, poolMarker); - await wait(50); - expect(renderUpdates).toBe(0); + await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); // The session render issued at editor attach stays local: no adoption // of the refused result, and the local highlight lands when ready. - let result = renderer.renderFile(file); + let result = renderer.renderFile(editSessionFile); if (result == null) { throw new Error('expected a render result'); } expect(toHtml(result.contentAST)).not.toContain('data-pool-result'); await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); - result = renderer.renderFile(file); + result = renderer.renderFile(editSessionFile); if (result == null) { throw new Error('expected a render result'); } @@ -217,7 +328,7 @@ describe('FileRenderer edit session', () => { } }); - test('an in-session hydrate does not preload from the pool', async () => { + test('hydrating a file during editing does not request a worker render', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -227,8 +338,9 @@ describe('FileRenderer edit session', () => { undefined, manager ); - renderer.beginEditSession(); - renderer.hydrate(createFile('file:hydrate')); + const editSessionFile = createEditSessionFile(createFile('file:hydrate')); + renderer.beginEditSession(editSessionFile); + renderer.hydrate(editSessionFile); await wait(50); expect(worker.fileRequestCount).toBe(0); } finally { @@ -236,7 +348,7 @@ describe('FileRenderer edit session', () => { } }); - test('a dirty edit session ends without resurrecting pre-edit pool markup', async () => { + test('ending an edit session preserves private edits without changing the external file', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -259,9 +371,11 @@ describe('FileRenderer edit session', () => { plainFileCode(FILE_CONTENTS) ); - renderer.beginEditSession(); - renderer.renderFile(file); + const editSessionFile = createEditSessionFile(file); + renderer.beginEditSession(editSessionFile, file); + renderer.renderFile(editSessionFile); await waitFor(() => expect(renderUpdates).toBeGreaterThan(1)); + renderer.renderFile(editSessionFile); // Simulate an editor keystroke: line 0 rewritten, cache marked dirty. renderer.updateRenderCache( @@ -270,20 +384,19 @@ describe('FileRenderer edit session', () => { ); renderer.endEditSession(); - const result = renderer.renderFile(file); + const result = renderer.renderFile(editSessionFile); if (result == null) { throw new Error('expected a render result'); } - // Ending the session persists the session text into the file and - // evicts the stale pool cache instead of adopting pre-edit markup. - expect(file.contents).toContain('const edited = 1;'); + expect(file.contents).toBe(FILE_CONTENTS); + expect(editSessionFile.contents).toContain('const edited = 1;'); expect(toHtml(result.contentAST)).toContain('const edited = 1;'); } finally { manager.terminate(); } }); - test('ending the session returns rendering to the pool', async () => { + test('ending an edit session sends later renders back to the worker', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -294,25 +407,557 @@ describe('FileRenderer edit session', () => { () => renderUpdates++, manager ); - renderer.beginEditSession(); const file = createFile('file:detach'); + const editSessionFile = createEditSessionFile(file); + renderer.beginEditSession(editSessionFile, file); - renderer.renderFile(file); + renderer.renderFile(editSessionFile); await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); expect(worker.fileRequestCount).toBe(0); renderer.endEditSession(); - renderer.renderFile(file); + renderer.renderFile(editSessionFile); await withTimeout(worker.waitForFileRequest()); expect(worker.fileRequestCount).toBe(1); } finally { manager.terminate(); } }); + + test('editing a reused worker render does not change the external cache', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + useTokenTransformer: true, + }); + try { + const renderer = new FileRenderer( + { theme: 'pierre-dark' }, + undefined, + manager + ); + const externalFile = createFile('file:cached-external'); + const externalBefore = structuredClone(externalFile); + + renderer.renderFile(externalFile); + await respondWithRealFileHighlight(manager, worker, externalFile); + await waitFor(() => { + expect(manager.getFileResultCache(externalFile)).toBeDefined(); + }); + renderer.renderFile(externalFile); + const cachedExternalBefore = structuredClone( + manager.getFileResultCache(externalFile) + ); + + const editSessionFile = createEditSessionFile(externalFile); + renderer.beginEditSession(editSessionFile, externalFile); + expect(renderer.editorRenderReady()).toBe(true); + renderer.updateRenderCache( + new Map([[0, [[0, '#ffffff', 'const edited = true;']]]]), + 'dark' + ); + + expect(editSessionFile.contents).toContain('const edited = true;'); + expect(externalFile).toEqual(externalBefore); + expect(manager.getFileResultCache(externalFile)).toEqual( + cachedExternalBefore + ); + expect( + toHtml(manager.getFileResultCache(externalFile)?.result.code ?? []) + ).not.toContain('const edited = true;'); + } finally { + manager.terminate(); + } + }); +}); + +describe('FileRenderer worker rendering', () => { + test('keeps a hydrated file selected when its replacement cannot render synchronously', async () => { + const { manager } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const renderer = new FileRenderer( + { theme: 'pierre-dark' }, + undefined, + manager + ); + const currentFile: FileContents = { + name: 'current.ts', + contents: 'const alpha = 1;\n', + cacheKey: 'hydrated-file:a', + }; + const replacementFile: FileContents = { + name: 'replacement.ts', + contents: 'const beta = 2;\n', + cacheKey: 'hydrated-file:b', + }; + + try { + renderer.hydrate(currentFile); + expect(renderer.fileCache).toBe(currentFile); + expect(renderer.renderFile(currentFile)).toBeUndefined(); + expect(renderer.fileCache).toBe(currentFile); + expect(renderer.getFileForNextRender(replacementFile)).toBe(currentFile); + expect(renderer.renderFile(replacementFile)).toBeUndefined(); + expect(renderer.fileCache).toBe(currentFile); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('keeps the current highlighted file visible while highlighting its replacement', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + let renderUpdates = 0; + const renderer = new FileRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + const currentFile: FileContents = { + name: 'current.ts', + contents: 'const currentValue = 1;\n', + cacheKey: 'file:current', + }; + const replacementFile: FileContents = { + name: 'replacement.ts', + contents: 'const replacementValue = 2;\n', + cacheKey: 'file:replacement', + }; + + try { + const primeCurrent = manager.primeFileHighlightCache(currentFile); + await respondWithRealFileHighlight(manager, worker, currentFile); + await withTimeout(primeCurrent); + const currentResult = renderer.renderFile(currentFile); + expect(toHtml(currentResult?.contentAST ?? [])).toContain('currentValue'); + + const pendingResult = renderer.renderFile(replacementFile); + expect(renderer.getFileForNextRender(replacementFile)).toBe(currentFile); + expect(pendingResult?.file).toBe(currentFile); + expect(toHtml(pendingResult?.contentAST ?? [])).toContain('currentValue'); + expect(toHtml(pendingResult?.contentAST ?? [])).not.toContain( + 'replacementValue' + ); + + await waitFor(() => expect(worker.fileRequestCount).toBe(2)); + const replacementRequest = await withTimeout(worker.waitForFileRequest()); + expect(replacementRequest.file.cacheKey).toBe(replacementFile.cacheKey); + worker.respond({ + type: 'success', + requestType: 'file', + id: replacementRequest.id, + result: renderFileWithHighlighter( + replacementFile, + sharedHighlighter, + manager.getFileRenderOptions() + ), + options: manager.getFileRenderOptions(), + sentAt: Date.now(), + }); + await waitFor(() => + expect(renderer.getFileForNextRender(replacementFile)).toBe( + replacementFile + ) + ); + expect(renderer.getFileForNextRender(replacementFile)).toBe( + replacementFile + ); + expect(renderer.fileCache).toBe(currentFile); + const replacementResult = renderer.renderFile(replacementFile); + expect(renderer.fileCache).toBe(replacementFile); + expect(replacementResult?.file).toBe(replacementFile); + expect(toHtml(replacementResult?.contentAST ?? [])).toContain( + 'replacementValue' + ); + expect(toHtml(replacementResult?.contentAST ?? [])).not.toContain( + 'currentValue' + ); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); +}); + +describe('DiffHunksRenderer worker rendering', () => { + test('keeps a hydrated diff selected until its replacement highlight is ready', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + try { + const diffA = createWorkerDiff('hydrated:a', 'const alpha = 1;\n'); + const diffB = createWorkerDiff('hydrated:b', 'const beta = 2;\n'); + + renderer.hydrate(diffA); + const requestA = await withTimeout(worker.waitForDiffRequest()); + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + expect(renderer.renderDiff(diffB)).toBeUndefined(); + expect(renderer.diffCache).toBe(diffA); + + respondWithHighlightedDiff(manager, worker, requestA, diffA); + await waitFor(() => expect(worker.diffRequestCount).toBe(2)); + const requestB = await withTimeout(worker.waitForDiffRequest()); + expect(requestB.diff.cacheKey).toBe(diffB.cacheKey); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + + respondWithHighlightedDiff(manager, worker, requestB, diffB); + await waitFor(() => expect(renderUpdates).toBe(1)); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffB); + expect(renderer.diffCache).toBe(diffA); + + const renderedB = renderer.renderDiff(diffB); + expect(renderedB?.fileDiff).toBe(diffB); + expect(renderedDiffHtml(renderedB)).toContain('beta'); + expect(renderer.diffCache).toBe(diffB); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('keeps the current highlighted diff visible while highlighting its replacement', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + try { + const diffA = parseDiffFromFile( + { + name: 'pending.ts', + contents: 'const before = 0;\n', + cacheKey: 'pending:a:old', + }, + { + name: 'pending.ts', + contents: 'const alpha = 1;\n', + cacheKey: 'pending:a:new', + } + ); + const diffB = parseDiffFromFile( + { + name: 'pending.ts', + contents: 'const before = 0;\n', + cacheKey: 'pending:b:old', + }, + { + name: 'pending.ts', + contents: 'const beta = 2;\n', + cacheKey: 'pending:b:new', + } + ); + const options = manager.getDiffRenderOptions(); + const highlightedA = renderDiffWithHighlighter( + diffA, + sharedHighlighter, + options + ); + const highlightedB = renderDiffWithHighlighter( + diffB, + sharedHighlighter, + options + ); + renderer.renderDiff(diffA); + const requestA = await withTimeout(worker.waitForDiffRequest()); + worker.respond({ + type: 'success', + requestType: 'diff', + id: requestA.id, + result: highlightedA, + options, + sentAt: Date.now(), + }); + await waitFor(() => expect(renderUpdates).toBe(1)); + + const settledA = renderer.renderDiff(diffA); + const settledAHtml = renderedDiffHtml(settledA); + expect(settledA?.fileDiff).toBe(diffA); + expect(settledAHtml).toContain('alpha'); + expect(renderer.diffCache).toBe(diffA); + + renderUpdates = 0; + const whileBPending = renderer.renderDiff(diffB); + await waitFor(() => expect(worker.diffRequestCount).toBe(2)); + const requestB = await withTimeout(worker.waitForDiffRequest()); + + expect(requestB.diff.cacheKey).toBe(diffB.cacheKey); + expect(whileBPending?.fileDiff).toBe(diffA); + expect(renderedDiffHtml(whileBPending)).toBe(settledAHtml); + expect(renderedDiffHtml(whileBPending)).not.toContain('beta'); + expect(renderer.diffCache).toBe(diffA); + expect(renderUpdates).toBe(0); + + worker.respond({ + type: 'success', + requestType: 'diff', + id: requestB.id, + result: highlightedB, + options, + sentAt: Date.now(), + }); + await waitFor(() => expect(renderUpdates).toBe(1)); + + // Completing B only stages its highlighted result. A stays active until + // the next render transaction promotes B. + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffB); + expect(renderer.diffCache).toBe(diffA); + + const settledB = renderer.renderDiff(diffB); + const settledBHtml = renderedDiffHtml(settledB); + expect(settledB?.fileDiff).toBe(diffB); + expect(settledBHtml).toContain('beta'); + expect(settledBHtml).not.toContain('alpha'); + expect(renderer.diffCache).toBe(diffB); + + renderer.onHighlightSuccess(diffA, highlightedA, options); + expect(renderUpdates).toBe(1); + expect(renderer.diffCache).toBe(diffB); + expect(renderedDiffHtml(renderer.renderDiff(diffB))).toBe(settledBHtml); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('renders a plain-text replacement immediately instead of retaining highlighted content', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + undefined, + manager + ); + try { + const highlightedDiff = createWorkerDiff( + 'plain:a', + 'const highlighted = 1;\n' + ); + const plainTextDiff = createWorkerDiff( + 'plain:b', + 'plain replacement\n', + 'pending.txt' + ); + const current = await renderHighlightedDiff( + renderer, + manager, + worker, + highlightedDiff + ); + expect(renderedDiffHtml(current)).toContain('highlighted'); + + const replacement = renderer.renderDiff(plainTextDiff); + + expect(replacement?.fileDiff).toBe(plainTextDiff); + expect(renderedDiffHtml(replacement)).toContain('plain replacement'); + expect(renderedDiffHtml(replacement)).not.toContain('highlighted'); + expect(renderer.diffCache).toBe(plainTextDiff); + expect(worker.diffRequestCount).toBe(1); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('renders an already-cached replacement immediately instead of retaining highlighted content', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + undefined, + manager + ); + try { + const currentDiff = createWorkerDiff('cached:a', 'const current = 1;\n'); + const cachedDiff = createWorkerDiff('cached:b', 'const cached = 2;\n'); + const current = await renderHighlightedDiff( + renderer, + manager, + worker, + currentDiff + ); + expect(renderedDiffHtml(current)).toContain('current'); + + const primeCache = manager.primeDiffHighlightCache(cachedDiff); + await waitFor(() => expect(worker.diffRequestCount).toBe(2)); + const cachedRequest = await withTimeout(worker.waitForDiffRequest()); + respondWithHighlightedDiff(manager, worker, cachedRequest, cachedDiff); + await withTimeout(primeCache); + + expect(renderer.diffCache).toBe(currentDiff); + const replacement = renderer.renderDiff(cachedDiff); + + expect(replacement?.fileDiff).toBe(cachedDiff); + expect(renderedDiffHtml(replacement)).toContain('cached'); + expect(renderedDiffHtml(replacement)).not.toContain('current'); + expect(renderer.diffCache).toBe(cachedDiff); + expect(worker.diffRequestCount).toBe(2); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('keeps hydrated content selected until a local plain-text replacement is ready', async () => { + let renderUpdates = 0; + const renderer = new DeferredHighlighterDiffRenderer( + { theme: 'andromeeda' }, + () => renderUpdates++ + ); + try { + const diffA = createWorkerDiff('hydrated-local:a', 'const alpha = 1;\n'); + const diffB = createWorkerDiff( + 'hydrated-local:b', + 'plain replacement\n', + 'pending.txt' + ); + + renderer.hydrate(diffA); + expect(renderer.initializations).toHaveLength(1); + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + + expect(renderer.renderDiff(diffB)).toBeUndefined(); + expect(renderer.initializations).toHaveLength(2); + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + + const replacementHighlighter = await getSharedHighlighter({ + themes: ['andromeeda'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); + const replacementInitialization = renderer.initializations[1]; + if (replacementInitialization == null) { + throw new Error('Expected replacement highlighter initialization'); + } + replacementInitialization.resolve(replacementHighlighter); + await waitFor(() => expect(renderUpdates).toBe(1)); + + expect(renderer.getDiffForNextRender(diffB)).toBe(diffB); + expect(renderer.diffCache).toBe(diffA); + const renderedB = renderer.renderDiff(diffB); + expect(renderedB?.fileDiff).toBe(diffB); + expect(renderedDiffHtml(renderedB)).toContain('plain replacement'); + expect(renderer.diffCache).toBe(diffB); + } finally { + renderer.cleanUp(); + } + }); + + test('keeps a hydrated plain-text diff selected until a local highlighted replacement is ready', async () => { + let renderUpdates = 0; + const renderer = new DeferredHighlighterDiffRenderer( + { theme: 'ayu-dark' }, + () => renderUpdates++ + ); + try { + const diffA = createWorkerDiff( + 'hydrated-plain:a', + 'plain current\n', + 'current.txt' + ); + const diffB = createWorkerDiff( + 'hydrated-plain:b', + 'const replacement = 2;\n' + ); + + renderer.hydrate(diffA); + expect(renderer.initializations).toHaveLength(1); + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + + expect(renderer.renderDiff(diffB)).toBeUndefined(); + expect(renderer.initializations).toHaveLength(2); + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); + + const replacementHighlighter = await getSharedHighlighter({ + themes: ['ayu-dark'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); + const replacementInitialization = renderer.initializations[1]; + if (replacementInitialization == null) { + throw new Error('Expected replacement highlighter initialization'); + } + replacementInitialization.resolve(replacementHighlighter); + await waitFor(() => expect(renderUpdates).toBe(1)); + + expect(renderer.getDiffForNextRender(diffB)).toBe(diffB); + expect(renderer.diffCache).toBe(diffA); + const renderedB = renderer.renderDiff(diffB); + expect(renderedB?.fileDiff).toBe(diffB); + expect(renderedDiffHtml(renderedB)).toContain('replacement'); + expect(renderer.diffCache).toBe(diffB); + } finally { + renderer.cleanUp(); + } + }); + + test('keeps the rendered diff active until a local async replacement is promoted', async () => { + let renderUpdates = 0; + const renderer = new DeferredHighlighterDiffRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++ + ); + try { + const diffA = createWorkerDiff('local:a', 'const alpha = 1;\n'); + const diffB = createWorkerDiff('local:b', 'const beta = 2;\n'); + const settledA = renderer.renderDiff(diffA); + expect(settledA?.fileDiff).toBe(diffA); + expect(renderedDiffHtml(settledA)).toContain('alpha'); + + renderer.setOptions({ theme: 'github-dark' }); + const whileBPending = renderer.renderDiff(diffB); + expect(renderer.initializations).toHaveLength(1); + expect(whileBPending?.fileDiff).toBe(diffA); + expect(renderedDiffHtml(whileBPending)).toContain('alpha'); + expect(renderer.diffCache).toBe(diffA); + + const githubHighlighter = await getSharedHighlighter({ + themes: ['github-dark'], + langs: ['typescript'], + preferredHighlighter: 'shiki-js', + }); + const initialization = renderer.initializations[0]; + if (initialization == null) { + throw new Error('Expected a pending highlighter initialization'); + } + initialization.resolve(githubHighlighter); + await waitFor(() => expect(renderUpdates).toBe(1)); + + expect(renderer.diffCache).toBe(diffA); + expect(renderer.getDiffForNextRender(diffB)).toBe(diffB); + expect(renderer.diffCache).toBe(diffA); + + const settledB = renderer.renderDiff(diffB); + expect(settledB?.fileDiff).toBe(diffB); + expect(renderedDiffHtml(settledB)).toContain('beta'); + expect(renderedDiffHtml(settledB)).not.toContain('alpha'); + } finally { + renderer.cleanUp(); + } + }); }); describe('DiffHunksRenderer edit session', () => { - test('a session render skips the pool and drops late pool results', async () => { + test('editing renders the diff locally and ignores a worker result that finishes late', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -323,7 +968,7 @@ describe('DiffHunksRenderer edit session', () => { () => renderUpdates++, manager ); - const diff = parseDiffFromFile( + const externalDiff = parseDiffFromFile( { name: 'demo.ts', contents: 'const value = "old";\n', @@ -335,12 +980,13 @@ describe('DiffHunksRenderer edit session', () => { cacheKey: 'd:new', } ); + const sessionDiff = createKeylessSessionDiff(externalDiff); - renderer.renderDiff(diff); + renderer.renderDiff(externalDiff); const request = await withTimeout(worker.waitForDiffRequest()); expect(worker.diffRequestCount).toBe(1); - renderer.beginEditSession(); + renderer.beginEditSession(sessionDiff, externalDiff); respondToDiffRequest(manager, worker, request); // Refused outright: nothing is applied and nothing is requested — the // session render issued at editor attach supplies the highlight. @@ -349,10 +995,10 @@ describe('DiffHunksRenderer edit session', () => { // The session render stays local and completes the highlight with // editor-compatible markup. - renderer.renderDiff(diff); + renderer.renderDiff(sessionDiff); expect(worker.diffRequestCount).toBe(1); await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); - const result = renderer.renderDiff(diff); + const result = renderer.renderDiff(sessionDiff); if (result == null) { throw new Error('expected a render result'); } @@ -362,13 +1008,15 @@ describe('DiffHunksRenderer edit session', () => { ...(result.deletionsContentAST ?? []), ]); expect(html).toContain('data-char'); + expect(renderer.diffCache).toBe(sessionDiff); + expect(sessionDiff.cacheKey).toBeUndefined(); expect(worker.diffRequestCount).toBe(1); } finally { manager.terminate(); } }); - test('an in-session refresh re-highlights locally instead of through the pool', async () => { + test('refreshing highlights during editing does not request a worker render', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -379,8 +1027,7 @@ describe('DiffHunksRenderer edit session', () => { () => renderUpdates++, manager ); - renderer.beginEditSession(); - const diff = parseDiffFromFile( + const externalDiff = parseDiffFromFile( { name: 'demo.ts', contents: 'const value = "old";\n', @@ -392,8 +1039,10 @@ describe('DiffHunksRenderer edit session', () => { cacheKey: 'r:new', } ); + const sessionDiff = createKeylessSessionDiff(externalDiff); - renderer.renderDiff(diff); + renderer.beginEditSession(sessionDiff, externalDiff); + renderer.renderDiff(sessionDiff); await waitFor(() => expect(renderUpdates).toBeGreaterThan(0)); expect(worker.diffRequestCount).toBe(0); @@ -403,10 +1052,338 @@ describe('DiffHunksRenderer edit session', () => { manager.terminate(); } }); + + test('entering edit mode reuses editor-compatible worker markup without modifying its cached copy', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + useTokenTransformer: true, + }); + try { + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + const externalDiff = parseDiffFromFile( + { + name: 'cached.ts', + contents: 'const value = "old";\n', + cacheKey: 'cached:old', + }, + { + name: 'cached.ts', + contents: 'const value = "new";\n', + cacheKey: 'cached:new', + } + ); + const sessionDiff = createKeylessSessionDiff(externalDiff); + + renderer.renderDiff(externalDiff); + respondWithHighlightedDiff( + manager, + worker, + await withTimeout(worker.waitForDiffRequest()), + externalDiff + ); + await waitFor(() => { + expect(manager.getDiffResultCache(externalDiff)).toBeDefined(); + }); + const renderedExternal = renderer.renderDiff(externalDiff); + expect(renderedExternal?.fileDiff).toBe(externalDiff); + const cachedExternalBefore = manager.getDiffResultCache(externalDiff); + if (cachedExternalBefore == null) { + throw new Error('expected a cached external result'); + } + const cachedExternalSnapshot = structuredClone(cachedExternalBefore); + const renderUpdatesBeforeSession = renderUpdates; + + renderer.beginEditSession(sessionDiff, externalDiff); + expect(renderer.editorRenderReady()).toBe(true); + expect(renderer.diffCache).toBe(sessionDiff); + expect(renderUpdates).toBe(renderUpdatesBeforeSession); + + renderer.beginEditSession(sessionDiff); + sessionDiff.additionLines = [...sessionDiff.additionLines]; + renderer.updateRenderCache( + new Map([ + [0, [[0, '', 'const edited = true;']]], + ]), + 'dark' + ); + + const cachedExternalResult = manager.getDiffResultCache(externalDiff); + expect(cachedExternalResult).toEqual(cachedExternalSnapshot); + expect( + toHtml(cachedExternalResult?.result.code.additionLines ?? []) + ).not.toContain('const edited = true;'); + expect(renderer.diffCache).toBe(sessionDiff); + expect(sessionDiff.additionLines[0]).toBe('const edited = true;\n'); + expect(sessionDiff.cacheKey).toBeUndefined(); + expect(worker.diffRequestCount).toBe(1); + } finally { + manager.terminate(); + } + }); + + test('entering edit mode does not reuse highlighted markup before it renders', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + useTokenTransformer: true, + }); + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + try { + const externalDiff = createWorkerDiff( + 'pending-edit', + 'const pendingEdit = true;\n' + ); + const sessionDiff = createKeylessSessionDiff(externalDiff); + + renderer.renderDiff(externalDiff); + respondWithHighlightedDiff( + manager, + worker, + await withTimeout(worker.waitForDiffRequest()), + externalDiff + ); + await waitFor(() => { + expect(manager.getDiffResultCache(externalDiff)).toBeDefined(); + }); + + renderer.beginEditSession(sessionDiff, externalDiff); + expect(renderer.editorRenderReady()).toBe(false); + + const updatesBeforeSessionRender = renderUpdates; + renderer.renderDiff(sessionDiff); + await waitFor(() => { + expect(renderUpdates).toBeGreaterThan(updatesBeforeSessionRender); + }); + const result = renderer.renderDiff(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); + expect(result?.fileDiff).toBe(sessionDiff); + expect(renderedDiffHtml(result)).toContain('data-char'); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); + + test('entering edit mode rehighlights settled markup without editor token metadata', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + }); + try { + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + const externalDiff = parseDiffFromFile( + { + name: 'incompatible.ts', + contents: 'const value = "old";\n', + cacheKey: 'incompatible:old', + }, + { + name: 'incompatible.ts', + contents: 'const value = "new";\n', + cacheKey: 'incompatible:new', + } + ); + const sessionDiff = createKeylessSessionDiff(externalDiff); + + renderer.renderDiff(externalDiff); + respondToDiffRequest( + manager, + worker, + await withTimeout(worker.waitForDiffRequest()) + ); + await waitFor(() => expect(renderUpdates).toBe(1)); + + renderer.beginEditSession(sessionDiff, externalDiff); + expect(renderer.editorRenderReady()).toBe(false); + + const updatesBeforeSessionHighlight = renderUpdates; + renderer.renderDiff(sessionDiff); + await waitFor(() => + expect(renderUpdates).toBeGreaterThan(updatesBeforeSessionHighlight) + ); + const result = renderer.renderDiff(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); + if (result == null) { + throw new Error('expected an editor-compatible session render'); + } + const html = toHtml([ + ...(result.unifiedContentAST ?? []), + ...(result.additionsContentAST ?? []), + ...(result.deletionsContentAST ?? []), + ]); + + expect(html).toContain('data-char'); + expect(renderer.diffCache).toBe(sessionDiff); + expect(sessionDiff.cacheKey).toBeUndefined(); + expect(worker.diffRequestCount).toBe(1); + } finally { + manager.terminate(); + } + }); + + test('entering edit mode does not reuse settled markup from another diff', async () => { + const { manager, worker } = await createInitializedManager({ + theme: 'pierre-dark', + useTokenTransformer: true, + }); + try { + let renderUpdates = 0; + const renderer = new DiffHunksRenderer( + { theme: 'pierre-dark' }, + () => renderUpdates++, + manager + ); + const renderedExternalDiff = parseDiffFromFile( + { + name: 'rendered.ts', + contents: 'const before = 0;\n', + cacheKey: 'rendered:old', + }, + { + name: 'rendered.ts', + contents: 'const rendered = 1;\n', + cacheKey: 'rendered:new', + } + ); + const sessionExternalDiff = parseDiffFromFile( + { + name: 'session.ts', + contents: 'const before = 0;\n', + cacheKey: 'session:old', + }, + { + name: 'session.ts', + contents: 'const session = 2;\n', + cacheKey: 'session:new', + } + ); + const sessionDiff = createKeylessSessionDiff(sessionExternalDiff); + + renderer.renderDiff(renderedExternalDiff); + respondToDiffRequest( + manager, + worker, + await withTimeout(worker.waitForDiffRequest()) + ); + await waitFor(() => expect(renderUpdates).toBe(1)); + + renderer.beginEditSession(sessionDiff, sessionExternalDiff); + expect(renderer.editorRenderReady()).toBe(false); + + const updatesBeforeSessionHighlight = renderUpdates; + renderer.renderDiff(sessionDiff); + await waitFor(() => + expect(renderUpdates).toBeGreaterThan(updatesBeforeSessionHighlight) + ); + const result = renderer.renderDiff(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); + if (result == null) { + throw new Error('expected an editor-compatible session render'); + } + const html = toHtml([ + ...(result.unifiedContentAST ?? []), + ...(result.additionsContentAST ?? []), + ...(result.deletionsContentAST ?? []), + ]); + + expect(html).toContain('session'); + expect(html).not.toContain('rendered'); + expect(renderer.diffCache).toBe(sessionDiff); + expect(sessionDiff.cacheKey).toBeUndefined(); + expect(worker.diffRequestCount).toBe(1); + } finally { + manager.terminate(); + } + }); + + test('an older highlight result cannot overwrite the diff being edited', async () => { + const renderer = new DeferredHighlighterDiffRenderer({ + theme: 'pierre-dark', + }); + try { + // Exercise the renderer's async initialization path without resetting + // the shared highlighter used by the rest of this file. + renderer.recycle(); + const externalDiff = parseDiffFromFile( + { + name: 'stale.ts', + contents: 'const value = "old";\n', + }, + { + name: 'stale.ts', + contents: 'const staleResult = true;\n', + } + ); + const sessionDiff = createKeylessSessionDiff(externalDiff); + + renderer.renderDiff(externalDiff); + expect(renderer.initializations).toHaveLength(1); + renderer.beginEditSession(sessionDiff); + renderer.renderDiff(sessionDiff); + expect(renderer.initializations).toHaveLength(2); + + const staleInitialization = renderer.initializations[0]; + const sessionInitialization = renderer.initializations[1]; + if (staleInitialization == null || sessionInitialization == null) { + throw new Error('expected two pending highlighter initializations'); + } + + sessionInitialization.resolve(sharedHighlighter); + await wait(0); + renderer.renderDiff(sessionDiff); + expect(renderer.diffCache).toBe(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); + + sessionDiff.additionLines = [...sessionDiff.additionLines]; + renderer.updateRenderCache( + new Map([ + [0, [[0, '', 'const sessionResult = true;']]], + ]), + 'dark' + ); + + const renderSessionHtml = (): string => { + const result = renderer.renderDiff(sessionDiff); + if (result == null) { + throw new Error('expected a session render result'); + } + return toHtml([ + ...(result.unifiedContentAST ?? []), + ...(result.additionsContentAST ?? []), + ...(result.deletionsContentAST ?? []), + ]); + }; + + expect(renderSessionHtml()).toContain('sessionResult'); + + staleInitialization.resolve(sharedHighlighter); + await wait(0); + + expect(renderer.diffCache).toBe(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); + expect(renderSessionHtml()).toContain('sessionResult'); + expect(renderSessionHtml()).not.toContain('staleResult'); + } finally { + renderer.cleanUp(); + } + }); }); describe('File component edit session', () => { - test('attaching an editor starts the session; detaching ends it', async () => { + test('attaching an editor switches to editor-compatible markup and detaching returns rendering to the worker', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -448,16 +1425,24 @@ describe('File component edit session', () => { }); expect(worker.fileRequestCount).toBe(1); - // With the session over, the next render adopts the pool's cached - // (non-transformer) result: pool markup replaces the editor markup. + // The private session is keyless, so the post-edit worker render cannot + // reuse the external file's cached result. detach(); instance.rerender(); + expect(fileContainer.shadowRoot?.innerHTML ?? '').toContain('data-char'); + await waitFor(() => expect(worker.fileRequestCount).toBe(2)); + const detachedRequest = await withTimeout(worker.waitForFileRequest()); + respondToFileRequest( + manager, + worker, + detachedRequest, + plainFileCode(FILE_CONTENTS) + ); await waitFor(() => { expect(fileContainer.shadowRoot?.innerHTML ?? '').not.toContain( 'data-char' ); }); - expect(worker.fileRequestCount).toBe(1); instance.cleanUp(); } finally { manager.terminate(); @@ -467,7 +1452,7 @@ describe('File component edit session', () => { }); describe('FileDiff component edit session', () => { - test('an attached editor renders the diff locally with token markup', async () => { + test('attaching an editor renders the diff locally with editor-compatible markup', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -534,18 +1519,13 @@ async function respondWithRealFileHighlight( file: FileContents ): Promise { const request = await withTimeout(worker.waitForFileRequest()); - const highlighter = await getSharedHighlighter({ - themes: ['pierre-dark'], - langs: ['typescript'], - preferredHighlighter: 'shiki-js', - }); worker.respond({ type: 'success', requestType: 'file', id: request.id, result: renderFileWithHighlighter( file, - highlighter, + sharedHighlighter, manager.getFileRenderOptions() ), options: manager.getFileRenderOptions(), @@ -553,8 +1533,8 @@ async function respondWithRealFileHighlight( }); } -describe('editor attach entry', () => { - test('attaching to a settled transformer-pool render needs no re-render', async () => { +describe('rendering when an editor attaches', () => { + test('reuses an existing editor-compatible worker render for a file', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -604,7 +1584,7 @@ describe('editor attach entry', () => { } }); - test('a non-transformer pool render gets one session render at attach; siblings untouched', async () => { + test('rerenders only the edited file when its worker render is not editor-compatible', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -668,8 +1648,7 @@ describe('editor attach entry', () => { ); }); - // One session render at attach plus its async highlight completion. - expect(updates - updatesBefore).toBe(2); + expect(updates - updatesBefore).toBeGreaterThan(0); expect(siblingUpdates).toBe(siblingUpdatesBefore); expect(siblingContainer.shadowRoot?.innerHTML ?? '').not.toContain( 'data-char' @@ -684,7 +1663,7 @@ describe('editor attach entry', () => { } }); - test('attaching while the pool highlight is in flight starts the local highlight immediately', async () => { + test('renders locally without waiting for a pending worker result and ignores it when it finishes', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -735,7 +1714,7 @@ describe('editor attach entry', () => { } }); - test('first edit of a settled virtualized diff replaces nothing (playground repro)', async () => { + test('entering edit mode reuses an existing editor-compatible render', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -788,18 +1767,13 @@ describe('editor attach entry', () => { const request = await withTimeout(worker.waitForDiffRequest()); // Deliver a genuine transformer-shaped highlight, as a configured // pool worker would. - const highlighter = await getSharedHighlighter({ - themes: ['pierre-dark'], - langs: ['typescript'], - preferredHighlighter: 'shiki-js', - }); worker.respond({ type: 'success', requestType: 'diff', id: request.id, result: renderDiffWithHighlighter( fileDiff, - highlighter, + sharedHighlighter, manager.getDiffRenderOptions() ), options: manager.getDiffRenderOptions(), @@ -811,25 +1785,28 @@ describe('editor attach entry', () => { ); }); + const callsBefore = instanceChangedCalls; const contentBefore = fileContainer.shadowRoot?.querySelector('[data-content]'); const lineBefore = fileContainer.shadowRoot?.querySelector('[data-line="1"]'); - const callsBefore = instanceChangedCalls; + expect(contentBefore).not.toBeNull(); + expect(lineBefore).not.toBeNull(); const detach = editor.edit(instance); - // The zero-render path must still deliver a working attachment. + // Compatible transformer markup is retained while its renderer cache is + // moved onto the private, keyless session model. await waitFor(() => expect(attaches).toBe(1)); + await wait(50); expect(instanceChangedCalls).toBe(callsBefore); - expect( - fileContainer.shadowRoot?.querySelector('[data-content]') === - contentBefore - ).toBe(true); - expect( - fileContainer.shadowRoot?.querySelector('[data-line="1"]') === - lineBefore - ).toBe(true); + expect(fileContainer.shadowRoot?.querySelector('[data-content]')).toBe( + contentBefore + ); + expect(fileContainer.shadowRoot?.querySelector('[data-line="1"]')).toBe( + lineBefore + ); + expect(editor.getFile()?.cacheKey).toBeUndefined(); expect(instance.options.useTokenTransformer).toBeUndefined(); expect(worker.diffRequestCount).toBe(1); detach(); @@ -841,7 +1818,7 @@ describe('editor attach entry', () => { } }); - test('a settled no-pool transformer render attaches with zero re-renders', async () => { + test('reuses an existing editor-compatible local render', async () => { const dom = installDom(); try { let updates = 0; @@ -885,7 +1862,7 @@ describe('editor attach entry', () => { // The option snapshots map shouldUseTokenTransformer, so token callbacks // alone give a no-pool render its data-char markup — which also means an // editor can attach to it without triggering a re-render. - test('token callbacks alone produce data-char markup, so an editor attaches without re-rendering', async () => { + test('reuses the initial render when token callbacks already made it editor-compatible', async () => { const dom = installDom(); try { let updates = 0; @@ -934,7 +1911,7 @@ describe('local highlighter engine', () => { // a local initialization on a pool-backed surface must consult the pool's // configured engine instead of seeding the singleton from component // defaults. - test('local highlighter initialization consults the pool engine preference', async () => { + test("file and diff renderers use the worker pool's preferred engine for local highlighting", async () => { const { manager } = await createInitializedManager({ theme: 'pierre-dark', }); diff --git a/packages/diffs/test/hydration.test.ts b/packages/diffs/test/hydration.test.ts index c4e3621b9..25d7c52c1 100644 --- a/packages/diffs/test/hydration.test.ts +++ b/packages/diffs/test/hydration.test.ts @@ -194,6 +194,8 @@ function createVirtualizer() { return { top: 0, bottom: 0 }; }, instanceChanged() {}, + markDOMDirty() {}, + requestHeightReconcile() {}, isInstanceVisible() { return false; }, diff --git a/packages/diffs/test/sparseLayoutCheckpoints.test.ts b/packages/diffs/test/sparseLayoutCheckpoints.test.ts index 68f865911..7da7a8747 100644 --- a/packages/diffs/test/sparseLayoutCheckpoints.test.ts +++ b/packages/diffs/test/sparseLayoutCheckpoints.test.ts @@ -211,7 +211,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); expect(instance.getLinePosition(10_000)?.top).toBe( metrics.diffHeaderHeight + 9_999 * metrics.lineHeight @@ -222,7 +222,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); expect(instance.getVirtualizedHeight()).toBe( metrics.diffHeaderHeight + 12_000 * metrics.lineHeight + metrics.spacing @@ -237,13 +237,13 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); expect(instance.getVirtualizedHeight()).toBe( metrics.diffHeaderHeight + 12_000 * metrics.lineHeight + metrics.spacing ); instance.cleanUp(true); - instance.prepareCodeViewItem(file, 0, undefined, []); + instance.updateCodeViewLayout(file, 0, undefined, []); expect(instance.getVirtualizedHeight()).toBe( metrics.diffHeaderHeight + 12_000 * metrics.lineHeight + metrics.spacing @@ -254,7 +254,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).cache.fileAnnotationHeight = 25; instance.height = metrics.diffHeaderHeight + @@ -276,7 +276,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).cache.fileAnnotationHeight = 25; instance.height = metrics.diffHeaderHeight + @@ -296,7 +296,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); const range = inspectFile(instance).computeRenderRangeFromWindow(file, 0, { top: metrics.diffHeaderHeight, @@ -311,7 +311,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).cache.fileAnnotationHeight = 0; const range = inspectFile(instance).computeRenderRangeFromWindow(file, 0, { @@ -331,7 +331,7 @@ describe('sparse layout checkpoints', () => { const instance = new VirtualizedFile({}, virtualizer, metrics); let annotationHeight = 25; - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).renderRange = createRenderRange(); inspectFile(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -376,7 +376,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).cache.fileAnnotationHeight = 25; inspectFile(instance).renderRange = { startingLine: 0, @@ -403,7 +403,7 @@ describe('sparse layout checkpoints', () => { fileAnnotationHeight + startingLine * metrics.lineHeight; - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).cache.fileAnnotationHeight = fileAnnotationHeight; inspectFile(instance).renderRange = { startingLine, @@ -424,7 +424,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).renderRange = createRenderRange(); inspectFile(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -465,7 +465,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).renderRange = createRenderRange(); inspectFile(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -500,7 +500,7 @@ describe('sparse layout checkpoints', () => { const file = createLargeFile(); const instance = new VirtualizedFile({}, virtualizer, metrics); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 0 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 0 }]); inspectFile(instance).renderRange = createRenderRange(); inspectFile(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -519,7 +519,7 @@ describe('sparse layout checkpoints', () => { metrics.spacing ); - instance.prepareCodeViewItem(file, 0, undefined, [{ lineNumber: 1 }]); + instance.updateCodeViewLayout(file, 0, undefined, [{ lineNumber: 1 }]); expect(inspectFile(instance).cache.fileAnnotationHeight).toBe(0); expect(instance.getVirtualizedHeight()).toBe( @@ -543,7 +543,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareCodeViewItem(diff, 0); + instance.updateCodeViewLayout(diff, 0); const expectedTop = metrics.diffHeaderHeight + 9_999 * metrics.lineHeight; expect(instance.getLinePosition(10_000, 'additions')?.top).toBe( @@ -578,7 +578,7 @@ describe('sparse layout checkpoints', () => { } const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(diff, 0); + instance.updateCodeViewLayout(diff, 0); expect( instance.getLinePosition(secondHunk.additionStart - 2, 'additions') @@ -599,7 +599,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareCodeViewItem(createLargeFile(), 0); + instance.updateCodeViewLayout(createLargeFile(), 0); instance.setOptions({ disableVirtualizationBuffers: true }); expect(layoutDirtyCalls).toEqual([false]); @@ -621,7 +621,7 @@ describe('sparse layout checkpoints', () => { metrics ); - instance.prepareCodeViewItem(parseDiffFromFile(oldFile, newFile), 0); + instance.updateCodeViewLayout(parseDiffFromFile(oldFile, newFile), 0); instance.setOptions({ diffIndicators: 'classic' }); expect(layoutDirtyCalls).toEqual([true]); diff --git a/packages/diffs/test/testUtils.ts b/packages/diffs/test/testUtils.ts index 944e67c4f..6eb103f7e 100644 --- a/packages/diffs/test/testUtils.ts +++ b/packages/diffs/test/testUtils.ts @@ -4,6 +4,24 @@ import { DEFAULT_COLLAPSED_CONTEXT_THRESHOLD } from '../src/constants'; import type { HunksRenderResult } from '../src/renderers/DiffHunksRenderer'; import type { FileDiffMetadata, ParsedPatch } from '../src/types'; +// Async test helpers + +export interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +export function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + // Assertion helpers export function assertDefined( diff --git a/packages/diffs/test/virtualFileMetricsPadding.test.ts b/packages/diffs/test/virtualFileMetricsPadding.test.ts index 64a028360..d30b00e9f 100644 --- a/packages/diffs/test/virtualFileMetricsPadding.test.ts +++ b/packages/diffs/test/virtualFileMetricsPadding.test.ts @@ -70,7 +70,7 @@ function createVirtualizedFile( ...baseMetrics, ...metrics, }); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); return instance; } @@ -86,7 +86,7 @@ function createVirtualizedFileDiff( ...baseMetrics, ...metrics, }); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); return instance; } @@ -129,7 +129,7 @@ describe('virtual file padding metrics', () => { contents: 'abcdef\nxyz', }; const instance = new VirtualizedFile({}, virtualizer, baseMetrics); - instance.prepareCodeViewItem(longFirstLineFile, 0); + instance.updateCodeViewLayout(longFirstLineFile, 0); expect(instance.getLinePosition(100)).toEqual({ top: baseMetrics.diffHeaderHeight + baseMetrics.lineHeight, @@ -202,7 +202,7 @@ describe('virtual file padding metrics', () => { paddingBottom: 13, }); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect(fileDiff.hunks.length).toBe(0); expect(instance.getVirtualizedHeight()).toBe( @@ -226,7 +226,7 @@ describe('virtual file padding metrics', () => { } ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect(instance.getVirtualizedHeight()).toBe( baseMetrics.diffHeaderHeight + 6 @@ -262,7 +262,7 @@ describe('virtual file padding metrics', () => { paddingTop: 50, paddingBottom: 60, }); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); const [firstHunk, secondHunk] = fileDiff.hunks; if (firstHunk == null || secondHunk == null) { @@ -304,7 +304,7 @@ describe('virtual file padding metrics', () => { (firstHunk.splitLineCount + secondHunk.splitLineCount) * codeViewLikeMetrics.lineHeight; - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect(firstHunk.collapsedBefore).toBeGreaterThan(0); expect(secondHunk.collapsedBefore).toBeGreaterThan(0); @@ -345,7 +345,7 @@ describe('virtual file padding metrics', () => { (firstHunk.splitLineCount + secondHunk.splitLineCount) * codeViewLikeMetrics.lineHeight; - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect( instance.getLinePosition(firstHunk.additionStart, 'additions')?.top @@ -392,7 +392,7 @@ describe('virtual file padding metrics', () => { (firstHunk.splitLineCount + secondHunk.splitLineCount) * codeViewLikeMetrics.lineHeight; - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect( instance.getLinePosition(firstHunk.additionStart, 'additions')?.top @@ -427,7 +427,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect(firstHunk.collapsedBefore).toBeGreaterThan(0); expect(secondHunk.collapsedBefore).toBeGreaterThan(0); @@ -462,7 +462,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect( instance.getLinePosition(secondHunk.additionStart, 'additions')?.top ).toBe( @@ -495,7 +495,7 @@ describe('virtual file padding metrics', () => { codeViewLikeMetrics ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); expect(firstHunk.collapsedBefore).toBeGreaterThan(0); expect(secondHunk.collapsedBefore).toBeGreaterThan(0); diff --git a/packages/diffs/test/virtualizedApplyDocumentChange.test.ts b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts index de73a03c9..647be66e6 100644 --- a/packages/diffs/test/virtualizedApplyDocumentChange.test.ts +++ b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { VirtualizedFile } from '../src/components/VirtualizedFile'; import type { + DiffsEditor, DiffsTextDocument, FileContents, RenderRange, @@ -49,6 +50,16 @@ function makeFile(lineCount: number): FileContents { return { name: 'a.txt', contents: makeContents(lineCount), lang: 'text' }; } +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + class BufferRecordingFile extends VirtualizedFile { public bufferUpdates = 0; @@ -61,6 +72,17 @@ class BufferRecordingFile extends VirtualizedFile { } } +// The buffer update runs after a content edit against the file represented by +// the existing DOM. These tests do not build DOM, so establish that ownership +// explicitly after attaching the private edit session. +function setRenderedEditSession(instance: BufferRecordingFile): void { + const state = instance as unknown as { + editSessionFile: FileContents | undefined; + renderedFile: FileContents | undefined; + }; + state.renderedFile = state.editSessionFile; +} + describe('applyDocumentChange buffer updates', () => { test('the buffer spacer update only runs in simple mode', () => { const seeded: RenderRange = { @@ -74,18 +96,27 @@ describe('applyDocumentChange buffer updates', () => { {}, createStubVirtualizer('advanced') ); - advancedInstance.prepareCodeViewItem(makeFile(50), 0); + advancedInstance.updateCodeViewLayout(makeFile(50), 0); + const detachAdvancedEditor = + advancedInstance.attachEditor(createEditorStub()); + setRenderedEditSession(advancedInstance); advancedInstance.seedRenderRange(seeded); advancedInstance.applyDocumentChange(makeDocument(1), undefined, true); expect(advancedInstance.bufferUpdates).toBe(0); + detachAdvancedEditor(); + advancedInstance.cleanUp(); const simpleInstance = new BufferRecordingFile( {}, createStubVirtualizer('simple') ); - simpleInstance.prepareCodeViewItem(makeFile(50), 0); + simpleInstance.updateCodeViewLayout(makeFile(50), 0); + const detachSimpleEditor = simpleInstance.attachEditor(createEditorStub()); + setRenderedEditSession(simpleInstance); simpleInstance.seedRenderRange(seeded); simpleInstance.applyDocumentChange(makeDocument(1), undefined, true); expect(simpleInstance.bufferUpdates).toBe(1); + detachSimpleEditor(); + simpleInstance.cleanUp(); }); }); diff --git a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts index a501dea13..0124ad20a 100644 --- a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts +++ b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts @@ -60,6 +60,7 @@ interface InspectableVirtualizedFileDiff { fileAnnotationHeight: number; }; lineAnnotations: unknown[]; + renderedDiff: FileDiffMetadata | undefined; renderRange: RenderRange | undefined; getExpandedLineCount( fileDiff: FileDiffMetadata, @@ -80,6 +81,15 @@ function inspect( return instance as unknown as InspectableVirtualizedFileDiff; } +// Height reconciliation measures existing DOM, so these focused unit tests +// explicitly identify the diff represented by their manually constructed rows. +function setRenderedDiff( + instance: VirtualizedFileDiff, + fileDiff: FileDiffMetadata +): void { + inspect(instance).renderedDiff = fileDiff; +} + function createRenderRange(startingLine = 0): RenderRange { return { startingLine, @@ -286,7 +296,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { test('computes split and unified estimates together on first prepare', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + instance.updateCodeViewLayout(createTwoHunkDiff(), 0); expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); @@ -304,7 +314,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { metrics ); - instance.prepareCodeViewItem(createHugeSingleBlockDiff(lineCount), 0); + instance.updateCodeViewLayout(createHugeSingleBlockDiff(lineCount), 0); expect(instance.getVirtualizedHeight()).toBe( metrics.diffHeaderHeight + @@ -318,7 +328,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const lineCount = 8; const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createHugeSingleBlockDiff(lineCount), 0); + instance.updateCodeViewLayout(createHugeSingleBlockDiff(lineCount), 0); inspect(instance).cache.estimatedSplitHeight = 123; inspect(instance).cache.estimatedUnifiedHeight = 456; inspect(instance).cache.heightDeltas.set(0, 7); @@ -356,12 +366,12 @@ describe('VirtualizedFileDiff estimated height cache', () => { }; const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); inspect(instance).cache.estimatedSplitHeight = 123; inspect(instance).cache.estimatedUnifiedHeight = 456; inspect(instance).cache.heightDeltas.set(0, 7); inspect(instance).cache.measuredHeightDeltaTotal = 7; - instance.prepareCodeViewItem(equivalentFileDiff, 0); + instance.updateCodeViewLayout(equivalentFileDiff, 0); expect(inspect(instance).cache.estimatedSplitHeight).toBe(123); expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(456); @@ -372,12 +382,12 @@ describe('VirtualizedFileDiff estimated height cache', () => { test('clears estimates and measurements for changed diff content', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff('first'), 0); + instance.updateCodeViewLayout(createTwoHunkDiff('first'), 0); inspect(instance).cache.estimatedSplitHeight = 123; inspect(instance).cache.estimatedUnifiedHeight = 456; inspect(instance).cache.heightDeltas.set(0, 7); inspect(instance).cache.measuredHeightDeltaTotal = 7; - instance.prepareCodeViewItem(createTwoHunkDiff('second'), 0); + instance.updateCodeViewLayout(createTwoHunkDiff('second'), 0); expect(inspect(instance).cache.estimatedSplitHeight).toBe(326); expect(inspect(instance).cache.estimatedUnifiedHeight).toBe(346); @@ -388,7 +398,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { test('reuses paired estimates across split and unified style changes', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + instance.updateCodeViewLayout(createTwoHunkDiff(), 0); inspect(instance).cache.heightDeltas.set(0, 7); inspect(instance).cache.measuredHeightDeltaTotal = 7; expect(instance.getLinePosition(40, 'additions')).toBeDefined(); @@ -413,7 +423,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { test('keeps paired estimates across collapse changes', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + instance.updateCodeViewLayout(createTwoHunkDiff(), 0); inspect(instance).cache.heightDeltas.set(0, 7); inspect(instance).cache.measuredHeightDeltaTotal = 7; expect(instance.getLinePosition(40, 'additions')).toBeDefined(); @@ -438,7 +448,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { test('recomputes paired estimates when hunk expansion changes', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + instance.updateCodeViewLayout(createTwoHunkDiff(), 0); inspect(instance).cache.heightDeltas.set(0, 7); inspect(instance).cache.measuredHeightDeltaTotal = 7; expect(instance.getLinePosition(40, 'additions')).toBeDefined(); @@ -464,7 +474,9 @@ describe('VirtualizedFileDiff estimated height cache', () => { ); let measuredHeight = 17; - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + const fileDiff = createTwoHunkDiff(); + instance.updateCodeViewLayout(fileDiff, 0); + setRenderedDiff(instance, fileDiff); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; inspect(instance).codeAdditions = createMeasuredCodeGroup( @@ -496,7 +508,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const lineCount = 1_000_000; - instance.prepareCodeViewItem( + instance.updateCodeViewLayout( createHugeSingleBlockDiff(lineCount), 0, undefined, @@ -519,7 +531,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); inspect(instance).cache.fileAnnotationHeight = 25; @@ -540,7 +552,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); inspect(instance).cache.fileAnnotationHeight = 25; @@ -559,7 +571,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createHugeSingleBlockDiff(1_000_000); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); @@ -581,7 +593,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { metrics ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); const separatorTop = metrics.diffHeaderHeight + lineCount * metrics.lineHeight; const range = inspect(instance).computeRenderRangeFromWindow(fileDiff, 0, { @@ -597,7 +609,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createHugeSingleBlockDiff(1_000_000); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); inspect(instance).cache.fileAnnotationHeight = 0; @@ -616,7 +628,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createNoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); @@ -635,9 +647,11 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); let annotationHeight = 25; - instance.prepareCodeViewItem(createTwoHunkDiff(), 0, undefined, [ + const fileDiff = createTwoHunkDiff(); + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -676,9 +690,11 @@ describe('VirtualizedFileDiff estimated height cache', () => { try { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0, undefined, [ + const fileDiff = createTwoHunkDiff(); + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -705,9 +721,10 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -743,9 +760,10 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -780,9 +798,10 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -798,7 +817,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(25); expect(instance.getVirtualizedHeight()).toBe(351); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 1 }, ]); @@ -816,9 +835,10 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const fileDiff = createTwoHunkDiff(); - instance.prepareCodeViewItem(fileDiff, 0, undefined, [ + instance.updateCodeViewLayout(fileDiff, 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); + setRenderedDiff(instance, fileDiff); inspect(instance).renderRange = createRenderRange(); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; @@ -836,9 +856,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { inspect(instance).fileContainer = undefined; instance.cleanUp(true); - expect(inspect(instance).lineAnnotations).toHaveLength(1); - - instance.prepareCodeViewItem(fileDiff, 0, undefined, []); + instance.updateCodeViewLayout(fileDiff, 0, undefined, []); expect(inspect(instance).cache.fileAnnotationHeight).toBe(0); expect(inspect(instance).cache.measuredHeightDeltaTotal).toBe(0); @@ -856,7 +874,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { metrics ); - instance.prepareCodeViewItem(createLargeExpandedDiff(), 0); + instance.updateCodeViewLayout(createLargeExpandedDiff(), 0); const estimatedHeight = instance.getVirtualizedHeight(); expect(inspect(instance).cache.totalLines).toBe(0); @@ -876,7 +894,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { metrics ); - instance.prepareCodeViewItem(createLargeExpandedDiff(), 0); + instance.updateCodeViewLayout(createLargeExpandedDiff(), 0); const estimatedHeight = instance.getVirtualizedHeight(); expect(inspect(instance).cache.totalLines).toBe(0); @@ -926,7 +944,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); const lineCount = 1_000_000; - instance.prepareCodeViewItem(createHugeSingleBlockDiff(lineCount), 0); + instance.updateCodeViewLayout(createHugeSingleBlockDiff(lineCount), 0); expect(instance.getLinePosition(900_000, 'additions')).toEqual({ top: metrics.diffHeaderHeight + 899_999 * metrics.lineHeight, @@ -958,7 +976,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { virtualizer, metrics ); - instance.prepareCodeViewItem(fileDiff, 0); + instance.updateCodeViewLayout(fileDiff, 0); const fileHeight = instance.getVirtualizedHeight(); inspect(instance).computeRenderRangeFromWindow(fileDiff, 0, { top: 0, diff --git a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts index 8da1cdce2..330eb2e5a 100644 --- a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts +++ b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts @@ -40,8 +40,14 @@ const virtualizer = { const codeView = { type: 'advanced' } as never; +class TestVirtualizedFile extends VirtualizedFile { + getLatestFileForTest(): FileContents | undefined { + return this.getLatestFile(); + } +} + describe('VirtualizedFile persisted layout', () => { - test('prepares cached contents before computing approximate height', () => { + test('restores cached contents before computing approximate height', () => { const dom = installDom(); const originalFile: FileContents = { name: 'file.ts', @@ -52,11 +58,11 @@ describe('VirtualizedFile persisted layout', () => { ...originalFile, contents: 'one\ntwo\nthree\nfour', }; - let prepareCalls = 0; + let restoreCalls = 0; const editor: DiffsEditor = { - __prepareFile() { - prepareCalls++; - return cachedFile; + __getCachedDocumentContents() { + restoreCalls++; + return cachedFile.contents; }, __captureFocusForDOMReplacement() {}, __postponeBgTokenizeToNextFrame() {}, @@ -66,7 +72,7 @@ describe('VirtualizedFile persisted layout', () => { }, cleanUp() {}, }; - const instance = new VirtualizedFile({}, virtualizer, metrics); + const instance = new TestVirtualizedFile({}, virtualizer, metrics); const detach = instance.attachEditor(editor); try { @@ -75,8 +81,11 @@ describe('VirtualizedFile persisted layout', () => { fileContainer: document.createElement('div'), }); - expect(prepareCalls).toBe(1); - expect(instance.file?.contents).toBe(cachedFile.contents); + expect(restoreCalls).toBe(1); + expect(instance.file).toBe(originalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + cachedFile.contents + ); expect(instance.getVirtualizedHeight()).toBe( getVirtualFileHeaderRegion(metrics, false) + 4 * metrics.lineHeight + @@ -89,7 +98,65 @@ describe('VirtualizedFile persisted layout', () => { } }); - test('recomputes height when an unkeyed file is mutated in place', () => { + test('does not restore cached contents over an attached host render', () => { + const dom = installDom(); + const originalFile: FileContents = { + name: 'file.ts', + contents: 'original', + cacheKey: 'file', + }; + const cachedFile: FileContents = { + ...originalFile, + contents: 'cached edit', + }; + const externalFile: FileContents = { + ...originalFile, + contents: 'external update', + cacheKey: 'file-v2', + }; + let restoreCalls = 0; + const editor: DiffsEditor = { + __getCachedDocumentContents(file) { + restoreCalls++; + return file.cacheKey === originalFile.cacheKey + ? cachedFile.contents + : undefined; + }, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + edit() { + return () => {}; + }, + cleanUp() {}, + }; + const instance = new TestVirtualizedFile({}, virtualizer, metrics); + const fileContainer = document.createElement('div'); + let detach: (() => void) | undefined; + + try { + instance.render({ file: originalFile, fileContainer }); + detach = instance.attachEditor(editor); + expect(restoreCalls).toBe(1); + expect(instance.file).toBe(originalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + cachedFile.contents + ); + + instance.render({ file: externalFile, fileContainer }); + expect(restoreCalls).toBe(1); + expect(instance.file).toBe(externalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + externalFile.contents + ); + } finally { + detach?.(); + instance.cleanUp(); + dom.cleanup(); + } + }); + + test('recomputes height for a new unkeyed file', () => { const dom = installDom(); const file: FileContents = { name: 'mutable.ts', @@ -106,8 +173,8 @@ describe('VirtualizedFile persisted layout', () => { getVirtualFilePaddingBottom(metrics) ); - file.contents = 'one\ntwo\nthree'; - instance.render({ file, fileContainer, forceRender: true }); + const nextFile = { ...file, contents: 'one\ntwo\nthree' }; + instance.render({ file: nextFile, fileContainer, forceRender: true }); expect(instance.getVirtualizedHeight()).toBe( getVirtualFileHeaderRegion(metrics, false) + 3 * metrics.lineHeight + @@ -119,7 +186,7 @@ describe('VirtualizedFile persisted layout', () => { } }); - test('recomputes CodeView height for an unkeyed in-place mutation', () => { + test('recomputes CodeView height for a new unkeyed file', () => { const file: FileContents = { name: 'mutable.ts', contents: 'one', @@ -128,12 +195,12 @@ describe('VirtualizedFile persisted layout', () => { const headerHeight = getVirtualFileHeaderRegion(metrics, false); const paddingBottom = getVirtualFilePaddingBottom(metrics); - expect(instance.prepareCodeViewItem(file, 0)).toBe( + expect(instance.updateCodeViewLayout(file, 0)).toBe( headerHeight + metrics.lineHeight + paddingBottom ); - file.contents = 'one\ntwo\nthree'; - expect(instance.prepareCodeViewItem(file, 0)).toBe( + const nextFile = { ...file, contents: 'one\ntwo\nthree' }; + expect(instance.updateCodeViewLayout(nextFile, 0)).toBe( headerHeight + 3 * metrics.lineHeight + paddingBottom ); });