From b21ee1ffe5c5186ae490e898d06365cfe0f69aad Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Thu, 6 Aug 2026 15:40:49 -0700 Subject: [PATCH 01/25] Phase 1: Clean up persisted state restoration --- packages/diffs/src/components/File.ts | 20 ++++-- packages/diffs/src/editor/editor.ts | 20 ++++-- packages/diffs/src/types.ts | 6 +- .../test/editorPersistStateLifecycle.test.ts | 35 ++++++++-- .../virtualizedFilePersistedLayout.test.ts | 65 +++++++++++++++++-- 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 99fccac34..8c25f526c 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -180,6 +180,10 @@ export class File< protected enabled = true; protected editor: DiffsEditor | undefined; + // When an editor attaches before the first file render, defer persisted + // document restoration to that render. Clearing this afterward keeps later + // host renders authoritative instead of replacing them with cached content. + private deferPersistedRestore = false; constructor( public options: FileOptions = { theme: DEFAULT_THEMES }, @@ -555,8 +559,9 @@ export class File< this.editor?.cleanUp(); this.editor = editor; this.fileRenderer.beginEditSession(); + this.deferPersistedRestore = this.file == null; const preparedFile = - this.file == null ? undefined : editor.__prepareFile?.(this.file); + this.file != null ? editor.__restoreCachedFile?.(this.file) : undefined; if (preparedFile !== undefined && preparedFile !== this.file) { this.renderPreparedFile({ file: preparedFile, @@ -573,6 +578,7 @@ export class File< this.rerender(); } return () => { + this.deferPersistedRestore = false; this.editor = undefined; this.fileRenderer.endEditSession(); }; @@ -616,14 +622,20 @@ export class File< ); } - const file = this.editor?.__prepareFile?.(props.file) ?? props.file; + let { file } = props; + if (this.deferPersistedRestore) { + file = this.editor?.__restoreCachedFile?.(file) ?? file; + this.deferPersistedRestore = false; + } else { + this.editor?.__persistFileState?.(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. + // Attachment-only restoration may replace the file before this shared phase; + // ordinary attached renders reach it with the host's supplied file unchanged. protected renderPreparedFile({ file, fileContainer, diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 57a23ad08..a92944813 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -827,10 +827,10 @@ export class Editor implements DiffsEditor { this.#fileInstance = undefined; } - /** @internal Capture outgoing state and substitute cached text before render. */ - __prepareFile(file: FileContents): FileContents { + /** @internal Persist the active File when key, name, or language changes. */ + __persistFileState(file: FileContents): string | undefined { if (this.#options.persistState !== true) { - return file; + return undefined; } const cacheKey = requirePersistedCacheKey(file); @@ -845,6 +845,16 @@ export class Editor implements DiffsEditor { this.#stateRestoreGeneration++; this.#persistCurrentState(); } + return cacheKey; + } + + /** @internal Return the File with cached document contents restored. */ + __restoreCachedFile(file: FileContents): FileContents { + const cacheKey = this.__persistFileState(file); + if (cacheKey === undefined) { + return file; + } + const textDocument = this.#getCachedTextDocument(file, cacheKey); if ( textDocument === undefined || @@ -980,8 +990,8 @@ export class Editor implements DiffsEditor { 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. + // A File attachment restores cached text before painting (see + // __restoreCachedFile), so its DOM always matches a reused document. const reusableTextDocument = fileInstance.type === 'file' || cachedTextDocument?.getText() === contents diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 67c013c7f..a9c0fab09 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1122,8 +1122,10 @@ export type EditableInstance = T extends { : T; export interface DiffsEditor { - /** @internal */ - __prepareFile?(file: FileContents): FileContents; + /** @internal Persist the active File when key, name, or language changes. */ + __persistFileState?(file: FileContents): string | undefined; + /** @internal Return the File with cached document contents restored. */ + __restoreCachedFile?(file: FileContents): FileContents; __postponeBgTokenizeToNextFrame(): void; /** @internal Capture focus intent before replacing the editable view. */ __captureFocusForDOMReplacement(): void; diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 15a6c0da5..896b5b917 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -98,6 +98,31 @@ function savedCaret(character: number): EditorState { } describe('Editor persisted state lifecycle', () => { + test('an attached render still validates the persistState cache key', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + let attached: AttachedFile | undefined; + + try { + attached = await attachFile(editor, { ...ORIGINAL_FILE }); + const current = attached; + + expect(() => + current.file.render({ + file: { name: 'unkeyed.ts', contents: 'updated\n' }, + fileContainer: current.container, + forceRender: true, + }) + ).toThrow( + 'Editor persistState requires a non-empty file.cacheKey for "unkeyed.ts".' + ); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + test('an async restore survives an unchanged file rerender', async () => { const dom = installDom(); const pendingState = createDeferred(); @@ -719,11 +744,11 @@ 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. + // A FileDiff renders straight from the host's metadata (no + // __restoreCachedFile 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 () => { const dom = installDom(); const editor = new Editor({ persistState: true }); diff --git a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts index 8da1cdce2..ee4a767ad 100644 --- a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts +++ b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts @@ -41,7 +41,7 @@ const virtualizer = { const codeView = { type: 'advanced' } as never; 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,10 +52,10 @@ describe('VirtualizedFile persisted layout', () => { ...originalFile, contents: 'one\ntwo\nthree\nfour', }; - let prepareCalls = 0; + let restoreCalls = 0; const editor: DiffsEditor = { - __prepareFile() { - prepareCalls++; + __restoreCachedFile() { + restoreCalls++; return cachedFile; }, __captureFocusForDOMReplacement() {}, @@ -75,7 +75,7 @@ describe('VirtualizedFile persisted layout', () => { fileContainer: document.createElement('div'), }); - expect(prepareCalls).toBe(1); + expect(restoreCalls).toBe(1); expect(instance.file?.contents).toBe(cachedFile.contents); expect(instance.getVirtualizedHeight()).toBe( getVirtualFileHeaderRegion(metrics, false) + @@ -89,6 +89,61 @@ describe('VirtualizedFile persisted layout', () => { } }); + 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', + }; + const filesPassedToPersist: FileContents[] = []; + let restoreCalls = 0; + const editor: DiffsEditor = { + __persistFileState(file) { + filesPassedToPersist.push(file); + return file.cacheKey; + }, + __restoreCachedFile() { + restoreCalls++; + return cachedFile; + }, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + edit() { + return () => {}; + }, + cleanUp() {}, + }; + const instance = new VirtualizedFile({}, 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(cachedFile); + + instance.render({ file: externalFile, fileContainer }); + expect(restoreCalls).toBe(1); + expect(filesPassedToPersist).toEqual([externalFile]); + expect(instance.file).toBe(externalFile); + } finally { + detach?.(); + instance.cleanUp(); + dom.cleanup(); + } + }); + test('recomputes height when an unkeyed file is mutated in place', () => { const dom = installDom(); const file: FileContents = { From df121e87c5fb002da19b2b53a1a8e557fb23a1aa Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 7 Aug 2026 15:38:19 -0700 Subject: [PATCH 02/25] Phase 2.1: Fixing up current session related diff for rendering properly --- packages/diffs/src/components/FileDiff.ts | 43 ++++++++++--------- .../src/components/VirtualizedFileDiff.ts | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index e924a229e..e29934c15 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -285,6 +285,7 @@ export class FileDiff< protected deletionFile?: FileContents | null; protected additionFile?: FileContents | null; public fileDiff: FileDiffMetadata | undefined; + private editSessionDiff: FileDiffMetadata | undefined; protected renderRange: RenderRange | undefined; protected pendingFiles: PendingFileLoad | undefined; protected appliedPreAttributes: PrePropertiesConfig | undefined; @@ -352,9 +353,9 @@ 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; + // Use the current diff so line indexes match what the component displays, + // including changes made during an active edit session. + const fileDiff = this.getCurrentDiff(); if (fileDiff == null) { return undefined; } @@ -697,6 +698,7 @@ export class FileDiff< this.workerManager = undefined; // Clean up the data this.fileDiff = undefined; + this.editSessionDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; } @@ -1016,22 +1018,21 @@ export class FileDiff< hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || !areOptionalFilesEqual(newFile, this.additionFile)); - const { fileDiffCache: sessionDiff } = this; + const currentDiff = this.getCurrentDiff(); 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) + currentDiff?.editSessionDirty === true && + fileDiff.cacheKey === currentDiff.cacheKey && + fileDiff.name === currentDiff.name && + fileDiff.lang === currentDiff.lang && + (fileDiff.cacheKey != null || fileDiff.prevName === currentDiff.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; + fileDiff = currentDiff; } let diffDidChange = fileDiff != null && fileDiff !== this.fileDiff; const annotationsChanged = @@ -1279,14 +1280,16 @@ export class FileDiff< onPostRender?.(fileContainer, this, phase); } - protected get fileDiffCache(): FileDiffMetadata | undefined { - return this.hunksRenderer.diffCache ?? this.fileDiff; + protected getCurrentDiff(): FileDiffMetadata | undefined { + return ( + this.editSessionDiff ?? this.hunksRenderer.diffCache ?? this.fileDiff + ); } private syncRenderViewToEditor(): void { const editor = this.editor; const fileContainer = this.fileContainer; - const fileDiff = this.fileDiffCache; + const fileDiff = this.getCurrentDiff(); const lineAnnotations = this.lineAnnotations; const renderRange = this.computeEditorRenderRange(this.renderRange); if ( @@ -1300,7 +1303,7 @@ export class FileDiff< !this.enabled || this.editor !== editor || this.fileContainer !== fileContainer || - this.fileDiffCache !== fileDiff + this.getCurrentDiff() !== fileDiff ) { return; } @@ -1324,7 +1327,7 @@ export class FileDiff< private computeEditorRenderRange( renderRange: RenderRange | undefined ): RenderRange | undefined { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getCurrentDiff(); if ( renderRange == null || fileDiff == null || @@ -1422,7 +1425,7 @@ export class FileDiff< * ran. */ public completeEditSession(): boolean { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getCurrentDiff(); if (fileDiff == null || fileDiff.editSessionDirty !== true) { return false; } @@ -1518,7 +1521,7 @@ export class FileDiff< // (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.getCurrentDiff(); if (fileDiff == null) { return true; } @@ -1542,7 +1545,7 @@ export class FileDiff< lineNumber: number, direction: 'up' | 'down' ): number | undefined { - const fileDiff = this.fileDiffCache; + const fileDiff = this.getCurrentDiff(); if (fileDiff == null) { return lineNumber; } @@ -1567,7 +1570,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.getCurrentDiff(); const { expandUnchanged = false, collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 76b20e160..0af90ba03 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -916,7 +916,7 @@ export class VirtualizedFileDiff< return true; } const { pendingExpansions } = this; - const fileDiff = this.fileDiffCache; + const fileDiff = this.getCurrentDiff(); if ( pendingExpansions == null || pendingExpansions.length === 0 || From dbbcf0b2ea064465e0a124df8a41bf58d9121737 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 8 Aug 2026 16:49:20 -0700 Subject: [PATCH 03/25] Phase 2.2: Setup shallow clone infra for session edits Also ensure things like hydration and stuff were setup properly in the new world --- packages/diffs/src/components/CodeView.ts | 11 +- packages/diffs/src/components/FileDiff.ts | 84 ++++++- .../src/components/VirtualizedFileDiff.ts | 20 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 32 ++- .../FileDiff.editSessionOwnership.test.ts | 199 +++++++++++++++++ .../test/FileDiff.partialHydration.test.ts | 211 ++++++++++++++++++ ...rtualizedFileDiff.partialHydration.test.ts | 132 ++++++++++- 7 files changed, 639 insertions(+), 50 deletions(-) create mode 100644 packages/diffs/test/FileDiff.editSessionOwnership.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index db35c8083..dfad39cfa 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -4015,16 +4015,7 @@ 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.instance.consumeCodeViewLayoutChanges(item.item.fileDiff); item.height = item.instance.prepareCodeViewItem( item.item.fileDiff, runningTop, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index e29934c15..9347f572a 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -131,6 +131,14 @@ 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; +} + export interface FileDiffRenderBaseProps { fileDiff?: FileDiffMetadata; deferManagers?: boolean; @@ -648,7 +656,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) { @@ -928,10 +938,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( @@ -950,10 +969,6 @@ export class FileDiff< throw error; } console.error(error); - } finally { - if (this.pendingFiles?.fileDiff === fileDiff) { - this.pendingFiles = undefined; - } } } @@ -966,6 +981,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; @@ -973,6 +992,27 @@ 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.editor == null || + this.editSessionDiff != null || + this.fileDiff !== expectedDiff + ) { + return false; + } + const editSessionDiff = createEditSessionDiff(expectedDiff); + this.editSessionDiff = editSessionDiff; + this.hunksRenderer.beginEditSession(editSessionDiff); + return true; + } + protected setHydratedState(files: LoadedPartialDiffContents): void { this.deletionFile = files.oldFile; this.additionFile = files.newFile; @@ -1381,7 +1421,14 @@ export class FileDiff< } this.editor?.cleanUp(); this.editor = editor; - this.hunksRenderer.beginEditSession(); + if ( + this.editSessionDiff == null && + this.fileDiff != null && + !this.fileDiff.isPartial + ) { + this.editSessionDiff = createEditSessionDiff(this.fileDiff); + } + 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) { @@ -1450,9 +1497,13 @@ export class FileDiff< textDocument: DiffsTextDocument, newLineAnnotations?: DiffLineAnnotation[] ): void { + this.detachAdditionLines(); + if (this.editSessionDiff != null) { + this.hunksRenderer.beginEditSession(this.editSessionDiff); + } this.hunksRenderer.applyDocumentChange(textDocument); const fileDiff = this.hunksRenderer.diffCache; - if (fileDiff != null) { + if (fileDiff != null && fileDiff !== this.editSessionDiff) { const cacheKey = this.fileDiff?.cacheKey; if (cacheKey != null && fileDiff.cacheKey == null) { fileDiff.cacheKey = cacheKey; @@ -1479,6 +1530,10 @@ export class FileDiff< lineCountChangeInFlight?: boolean; } = {} ): void { + this.detachAdditionLines(); + if (this.editSessionDiff != null) { + this.hunksRenderer.beginEditSession(this.editSessionDiff); + } const { shouldRefreshDiffsView, lineCountChangeInFlight } = options; const regionsChanged = this.hunksRenderer.updateRenderCache( dirtyLines, @@ -1517,6 +1572,17 @@ 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. diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 0af90ba03..76891ab62 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -847,9 +847,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(); @@ -860,9 +862,9 @@ export class VirtualizedFileDiff< public consumeCodeViewLayoutChanges( expectedFileDiff: FileDiffMetadata - ): FileDiffMetadata | undefined { + ): void { let hasLayoutChange = false; - let nextDiff: FileDiffMetadata | undefined; + let didHydrate = false; const { pendingExpansions, pendingHydratedDiff } = this; if (pendingExpansions != null) { @@ -880,20 +882,20 @@ export class VirtualizedFileDiff< if (pendingHydratedDiff != null) { this.pendingHydratedDiff = undefined; if (pendingHydratedDiff.expectedDiff === expectedFileDiff) { + Object.assign(expectedFileDiff, pendingHydratedDiff.nextDiff); this.setHydratedState(pendingHydratedDiff.files); - nextDiff = pendingHydratedDiff.nextDiff; + this.startHydratedEditSession(expectedFileDiff); + didHydrate = true; } } - if (nextDiff != null) { + if (didHydrate) { this.forceRenderOverride = true; this.resetLayoutCache({ includeEstimatedHeights: true }); } else if (hasLayoutChange) { this.forceRenderOverride = true; this.invalidateDerivedLayoutCache(true); } - - return nextDiff; } protected override loadFilesIfNecessary(): void { diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 0f81d345e..1e937621c 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -271,9 +271,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,19 +278,30 @@ 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). The current session diff is reinstalled + * after renderer-cache resets so mutation paths cannot reach external data. + * An empty additions document gets one row for the editor's caret. */ - public beginEditSession(): void { + public beginEditSession(diff?: FileDiffMetadata): void { this.editSessionActive = true; - const diff = this.diffCache; - if (diff != null && !diff.isPartial && diff.additionLines.length === 0) { + if (diff != null) { + this.diff = diff; + if (this.renderCache != null) { + this.renderCache.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(); } } diff --git a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts new file mode 100644 index 000000000..39e889135 --- /dev/null +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -0,0 +1,199 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { disposeHighlighter, FileDiff, parseDiffFromFile } from '../src'; +import type { + DiffsEditor, + DiffsTextDocument, + FileDiffMetadata, + HighlightedToken, +} from '../src/types'; +import { installDom, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +class TestFileDiff extends FileDiff { + getCurrentDiffForTest(): FileDiffMetadata | undefined { + return this.getCurrentDiff(); + } + + 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 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(): void; + externalDiff: FileDiffMetadata; + 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.getCurrentDiffForTest(); + return ( + sessionDiff != null && + sessionDiff !== externalDiff && + instance.isEditorRenderReadyForTest() + ); + }, + { timeout: 4_000 } + ); + const sessionDiff = instance.getCurrentDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff).not.toBe(externalDiff); + expect(instance.isEditorRenderReadyForTest()).toBe(true); + + return { + cleanup() { + instance.cleanUp(); + dom.cleanup(); + }, + detach, + externalDiff, + instance, + }; +} + +describe('FileDiff edit-session ownership', () => { + test('attach creates a private keyless shallow session diff', async () => { + const fixture = await createAttachedFixture(); + const { detach, externalDiff, instance } = fixture; + try { + const sessionDiff = instance.getCurrentDiffForTest(); + 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 = structuredClone(externalDiff); + try { + const sessionBefore = instance.getCurrentDiffForTest(); + 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.getCurrentDiffForTest(); + 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); + expect(externalDiff).toEqual(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 = structuredClone(externalDiff); + try { + const sessionBefore = instance.getCurrentDiffForTest(); + 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.getCurrentDiffForTest(); + 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(); + expect(externalDiff).toEqual(externalBefore); + } finally { + detach(); + fixture.cleanup(); + } + }); +}); diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 66b934f50..842d9e24b 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -8,9 +8,11 @@ import { parsePatchFiles, } from '../src'; import type { + DiffsEditor, FileContents, FileDiffLoadedFiles, FileDiffMetadata, + HighlightedToken, } from '../src/types'; import type { WorkerPoolManager } from '../src/worker'; import { installDom, wait } from './domHarness'; @@ -21,6 +23,14 @@ afterAll(async () => { }); class TestFileDiff extends FileDiff { + getCurrentDiffForTest() { + return this.getCurrentDiff(); + } + + getRendererDiffForTest() { + return this.hunksRenderer.diffCache; + } + getExpandedHunkForTest(index: number) { return this.hunksRenderer.getExpandedHunk(index); } @@ -44,6 +54,22 @@ class TestFileDiff extends FileDiff { } } +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; +} + +function makeDirtyLines( + edits: ReadonlyArray<[number, string]> +): Map { + return new Map(edits.map(([line, text]) => [line, [[0, '', text]]])); +} + function createDeferred(): { promise: Promise; resolve(value: T): void; @@ -281,6 +307,191 @@ 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.getCurrentDiffForTest(); + 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.getCurrentDiffForTest(); + 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('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.getCurrentDiffForTest(); + 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.getCurrentDiffForTest(); + 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(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/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 3af1ffd46..440a173c5 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -4,7 +4,7 @@ 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'; @@ -13,9 +13,27 @@ afterAll(async () => { }); class TestVirtualizedFileDiff extends VirtualizedFileDiff { + getCurrentDiffForTest() { + return this.getCurrentDiff(); + } + getExpandedHunkForTest(index: number) { return this.hunksRenderer.getExpandedHunk(index); } + + getPendingFileLoadPromiseForTest() { + return this.pendingFiles?.promise; + } +} + +function createEditorStub(): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + }; } function createDeferred(): { @@ -399,7 +417,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'); @@ -427,26 +445,120 @@ describe('VirtualizedFileDiff partial hydration', () => { { layoutDirty: true }, ]); - const nextDiff = instance.consumeCodeViewLayoutChanges(partial); + instance.consumeCodeViewLayoutChanges(partial); - 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', ]); + instance.prepareCodeViewItem(partial, 0); 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.prepareCodeViewItem(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.getCurrentDiffForTest()).toBe(partial); + + instance.cleanUp(true); + detach = undefined; + instance.consumeCodeViewLayoutChanges(partial); + + expect(instance.getCurrentDiffForTest()).toBe(partial); + expect(partial.isPartial).toBe(false); + + instance.virtualizedSetup(); + instance.prepareCodeViewItem(partial, 0); + detach = instance.attachEditor(editor); + + const sessionDiff = instance.getCurrentDiffForTest(); + 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('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.prepareCodeViewItem(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.getCurrentDiffForTest(); + 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; From 5f694585f0fb2a260240cb9b09e88bf52f691336 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 8 Aug 2026 17:17:55 -0700 Subject: [PATCH 04/25] Phase 2.3: Ensure only sessionDiff is edited --- packages/diffs/src/components/FileDiff.ts | 28 ++-- .../FileDiff.editSessionOwnership.test.ts | 150 +++++++++++++++++- .../test/FileDiff.partialHydration.test.ts | 1 + .../FileDiff.unifiedEditSeparators.test.ts | 15 +- 4 files changed, 174 insertions(+), 20 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 9347f572a..709418a6e 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1497,19 +1497,15 @@ export class FileDiff< textDocument: DiffsTextDocument, newLineAnnotations?: DiffLineAnnotation[] ): void { - this.detachAdditionLines(); - if (this.editSessionDiff != null) { - this.hunksRenderer.beginEditSession(this.editSessionDiff); + 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); - const fileDiff = this.hunksRenderer.diffCache; - if (fileDiff != null && fileDiff !== this.editSessionDiff) { - const cacheKey = this.fileDiff?.cacheKey; - if (cacheKey != null && fileDiff.cacheKey == null) { - fileDiff.cacheKey = cacheKey; - } - this.fileDiff = fileDiff; - } if ( newLineAnnotations !== undefined && newLineAnnotations !== this.lineAnnotations @@ -1530,10 +1526,14 @@ export class FileDiff< lineCountChangeInFlight?: boolean; } = {} ): void { - this.detachAdditionLines(); - if (this.editSessionDiff != null) { - this.hunksRenderer.beginEditSession(this.editSessionDiff); + 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, diff --git a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts index 39e889135..bfd76262a 100644 --- a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -1,9 +1,11 @@ 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'; @@ -46,6 +48,31 @@ function createExternalDiff(): FileDiffMetadata { 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 { @@ -65,7 +92,7 @@ function makeTextDocument(lines: string[]): DiffsTextDocument { async function createAttachedFixture(): Promise<{ cleanup(): void; - detach(): void; + detach(recycle?: boolean): void; externalDiff: FileDiffMetadata; instance: TestFileDiff; }> { @@ -113,6 +140,45 @@ async function createAttachedFixture(): Promise<{ } 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; @@ -137,7 +203,7 @@ describe('FileDiff edit-session ownership', () => { test('a same-line edit copies addition lines and keeps hunks shared', async () => { const fixture = await createAttachedFixture(); const { detach, externalDiff, instance } = fixture; - const externalBefore = structuredClone(externalDiff); + const externalBefore = captureExternalDiffState(externalDiff); try { const sessionBefore = instance.getCurrentDiffForTest(); expect(sessionBefore).toBeDefined(); @@ -159,7 +225,7 @@ describe('FileDiff edit-session ownership', () => { expect(sessionAfter?.hunks[0]).toBe(externalDiff.hunks[0]); expect(sessionAfter?.editSessionDirty).toBe(true); expect(instance.getRendererDiffForTest()).toBe(sessionAfter); - expect(externalDiff).toEqual(externalBefore); + expectExternalDiffUnchanged(instance, externalDiff, externalBefore); } finally { detach(); fixture.cleanup(); @@ -169,7 +235,7 @@ describe('FileDiff edit-session ownership', () => { test('a structural edit rebuilds an owned hunk graph', async () => { const fixture = await createAttachedFixture(); const { detach, externalDiff, instance } = fixture; - const externalBefore = structuredClone(externalDiff); + const externalBefore = captureExternalDiffState(externalDiff); try { const sessionBefore = instance.getCurrentDiffForTest(); expect(sessionBefore).toBeDefined(); @@ -190,10 +256,84 @@ describe('FileDiff edit-session ownership', () => { expect(sessionAfter?.hunks).not.toBe(externalDiff.hunks); expect(sessionAfter?.hunks[0]).not.toBe(externalDiff.hunks[0]); expect(sessionAfter?.cacheKey).toBeUndefined(); - expect(externalDiff).toEqual(externalBefore); + 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.getCurrentDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); + + detach(true); + instance.cleanUp(true); + + expect(instance.getCurrentDiffForTest()).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.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 842d9e24b..e78298178 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -483,6 +483,7 @@ describe('FileDiff partial hydration', () => { ); 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 { diff --git a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts index 09ca64d8b..c98d8eab4 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) { @@ -66,6 +76,7 @@ describe('FileDiff unified edit separators', () => { test('applyDocumentChange refreshes function hunk separators', async () => { const { cleanup } = installDom(); + let detach: (() => void) | undefined; let instance: FileDiff | undefined; try { const fileDiff = createTwoHunkDiff(); @@ -88,6 +99,7 @@ describe('FileDiff unified edit separators', () => { deferManagers: true, }); await waitForRenderedCode(fileContainer); + detach = instance.attachEditor(createEditorStub()); expect(countSeparatorSlots(fileContainer)).toBeGreaterThan(0); @@ -95,6 +107,7 @@ describe('FileDiff unified edit separators', () => { expect(countSeparatorSlots(fileContainer)).toBe(0); } finally { + detach?.(); instance?.cleanUp(); cleanup(); } From cec8bbf417e137d027b12512c3ec348f44ff9e2b Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 8 Aug 2026 18:31:48 -0700 Subject: [PATCH 05/25] Phase 2.4: Ensure sessionDiff is used for all rendering processes --- packages/diffs/src/components/CodeView.ts | 6 +- packages/diffs/src/components/FileDiff.ts | 85 +++++----- .../src/components/VirtualizedFileDiff.ts | 61 ++++---- packages/diffs/src/utils/editSessionHunks.ts | 2 +- packages/diffs/test/CodeView.edit.test.ts | 60 +++++-- .../FileDiff.editSessionOwnership.test.ts | 146 ++++++++++++++++++ .../diffs/test/FileDiff.headerSlots.test.ts | 71 +++++++++ .../test/FileDiff.rerenderInputs.test.ts | 73 +++------ .../FileDiff.unifiedEditSeparators.test.ts | 14 +- ...rtualizedFileDiff.partialHydration.test.ts | 39 +++++ 10 files changed, 415 insertions(+), 142 deletions(-) diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index dfad39cfa..e7c3e2d2d 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -2015,13 +2015,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; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 709418a6e..3f5b2cc02 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'; @@ -230,6 +231,7 @@ interface TrimColumnsToOverlapProps { } interface ApplyPartialRenderProps { + fileDiff: FileDiffMetadata; previousRenderRange: RenderRange | undefined; renderRange: RenderRange | undefined; } @@ -1058,23 +1060,8 @@ export class FileDiff< hasFileInput && (!areOptionalFilesEqual(oldFile, this.deletionFile) || !areOptionalFilesEqual(newFile, this.additionFile)); - const currentDiff = this.getCurrentDiff(); - if ( - fileDiff != null && - this.editor != null && - currentDiff?.editSessionDirty === true && - fileDiff.cacheKey === currentDiff.cacheKey && - fileDiff.name === currentDiff.name && - fileDiff.lang === currentDiff.lang && - (fileDiff.cacheKey != null || fileDiff.prevName === currentDiff.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 = currentDiff; - } - 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) @@ -1089,7 +1076,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)) @@ -1124,7 +1111,7 @@ export class FileDiff< this.additionFile = undefined; } - if (fileDiff != null) { + if (fileDiff != null && diffDidChange) { this.fileDiff = fileDiff; } else if (nextParsedFileDiff != null) { diffDidChange = true; @@ -1137,17 +1124,18 @@ export class FileDiff< if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } - if (this.fileDiff == null) { + const currentDiff = this.getCurrentDiff(); + if (currentDiff == 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 && + currentDiff.editSessionDirty === true && this.shouldSelfHealEditSession() ) { - finishEditSessionForDiff(this.fileDiff, this.options.parseDiffOptions); + finishEditSessionForDiff(currentDiff, this.options.parseDiffOptions); void this.hunksRenderer.refreshHighlightedResult(); } if (expandUnchanged) { @@ -1182,7 +1170,7 @@ export class FileDiff< try { const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + currentDiff, EMPTY_RENDER_RANGE ); if (hunksResult != null) { @@ -1194,7 +1182,11 @@ export class FileDiff< ); } if (hunksResult?.headerElement != null) { - this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); + this.applyHeaderToDOM( + hunksResult.headerElement, + fileContainer, + currentDiff + ); } this.renderSeparators([]); this.injectUnsafeCSS(); @@ -1224,6 +1216,7 @@ export class FileDiff< filesDidChange || diffDidChange || themeChanged ) && this.applyPartialRender({ + fileDiff: currentDiff, previousRenderRange, renderRange: nextRenderRange, }); @@ -1231,7 +1224,7 @@ export class FileDiff< // If we were unable to partially render, perform a full render if (!didPartiallyRender) { const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + currentDiff, nextRenderRange ); if (hunksResult == null) { @@ -1251,7 +1244,11 @@ export class FileDiff< ); if (hunksResult.headerElement != null) { - this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); + this.applyHeaderToDOM( + hunksResult.headerElement, + fileContainer, + currentDiff + ); } if ( hunksResult.additionsContentAST != null || @@ -1320,10 +1317,10 @@ export class FileDiff< onPostRender?.(fileContainer, this, phase); } - protected getCurrentDiff(): FileDiffMetadata | undefined { - return ( - this.editSessionDiff ?? this.hunksRenderer.diffCache ?? this.fileDiff - ); + protected getCurrentDiff( + fileDiff: FileDiffMetadata | undefined = this.fileDiff + ): FileDiffMetadata | undefined { + return this.editSessionDiff ?? fileDiff; } private syncRenderViewToEditor(): void { @@ -2100,13 +2097,18 @@ 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); + // Session metadata changes in place, so an HTML cache created from the + // external baseline cannot describe the current edit-session header. + const headerHTML = + fileDiff === this.editSessionDiff + ? toHtml(headerAST) + : (this.cachedHeaderHTML ?? toHtml(headerAST)); this.cachedHeaderHTML = headerHTML; if (headerHTML !== this.lastRenderedHeaderHTML) { const tempDiv = document.createElement('div'); @@ -2124,7 +2126,7 @@ export class FileDiff< this.lastRenderedHeaderHTML = headerHTML; } - if (this.isContainerManaged || fileDiff == null) { + if (this.isContainerManaged) { return; } @@ -2506,6 +2508,7 @@ export class FileDiff< } private applyPartialRender({ + fileDiff, previousRenderRange, renderRange, }: ApplyPartialRenderProps): boolean { @@ -2576,10 +2579,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, @@ -2678,12 +2681,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.getCurrentDiff(); + if (this.options.diffStyle !== 'split' || fileDiff == null) { return; } const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + fileDiff, this.renderRange ); if (hunksResult == null) { @@ -2742,12 +2746,13 @@ export class FileDiff< // full diff view re-rendering // only for unified view. private refreshUnifiedDiffView(): void { - if (this.options.diffStyle !== 'unified') { + const fileDiff = this.getCurrentDiff(); + if (this.options.diffStyle !== 'unified' || fileDiff == null) { return; } const hunksResult = this.hunksRenderer.renderDiff( - this.fileDiff, + fileDiff, this.renderRange ); if (hunksResult == null) { diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 76891ab62..80beb7704 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -194,7 +194,7 @@ export class VirtualizedFileDiff< } private hasFileAnnotations( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): boolean { if (fileDiff == null || !includesFileAnnotations(this.lineAnnotations)) { return false; @@ -323,7 +323,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.getCurrentDiff(); + if (this.fileContainer == null || fileDiff == null) { if (this.height !== 0) { hasHeightChange = true; } @@ -348,7 +349,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 && @@ -480,7 +481,9 @@ export class VirtualizedFileDiff< if (shouldResetLayoutCache) { this.resetLayoutCache({ includeEstimatedHeights }); } - this.fileDiff = fileDiff; + if (targetChanged) { + this.fileDiff = fileDiff; + } this.top = top; this.computeApproximateSize(); return this.height; @@ -490,7 +493,8 @@ export class VirtualizedFileDiff< lineNumber: number, side: SelectionSide = 'additions' ): { top: number; height: number } | undefined { - if (this.fileDiff == null || lineNumber < 1) { + const fileDiff = this.getCurrentDiff(); + if (fileDiff == null || lineNumber < 1) { return undefined; } @@ -523,7 +527,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 +625,8 @@ export class VirtualizedFileDiff< public getNumericScrollAnchor( localViewportTop: number ): NumericScrollLineAnchor | undefined { - if (this.fileDiff == null) { + const fileDiff = this.getCurrentDiff(); + if (fileDiff == null) { return undefined; } @@ -650,7 +655,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 +742,8 @@ export class VirtualizedFileDiff< public getAdvancedStickySpecs( windowSpecs?: RenderWindow ): StickySpecs | undefined { - if (this.top == null || this.fileDiff == null) { + const fileDiff = this.getCurrentDiff(); + if (this.top == null || fileDiff == null) { return undefined; } if (this.options.collapsed === true) { @@ -745,11 +751,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; @@ -1039,18 +1041,19 @@ export class VirtualizedFileDiff< resetRenderRange: false, }); + const fileDiff = this.getCurrentDiff(); 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 ); @@ -1070,7 +1073,7 @@ export class VirtualizedFileDiff< // if the height is 100% accurate private computeApproximateSize( force = false, - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): void { const shouldValidateSize = this.isResizeDebuggingEnabled(); if (!force && !this.layoutDirty && !shouldValidateSize) { @@ -1109,7 +1112,7 @@ export class VirtualizedFileDiff< } private getActiveEstimatedHeight( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): number { this.ensureEstimatedDiffHeights(fileDiff); const estimatedHeight = @@ -1125,7 +1128,7 @@ export class VirtualizedFileDiff< } private ensureEstimatedDiffHeights( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): void { if (fileDiff == null) { this.cache.estimatedSplitHeight = undefined; @@ -1162,7 +1165,7 @@ export class VirtualizedFileDiff< } private validateComputedHeight( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): void { if (this.fileContainer == null || fileDiff == null) { return; @@ -1221,10 +1224,9 @@ export class VirtualizedFileDiff< 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; + const dataChanged = targetChanged || filesDidChange; if (targetChanged) { this.resetLayoutCache({ includeEstimatedHeights: true }); } @@ -1237,9 +1239,10 @@ export class VirtualizedFileDiff< ); return false; } + const currentDiff = this.getCurrentDiff(nextFileDiff) ?? nextFileDiff; if (!isSetup) { - this.computeApproximateSize(false, nextFileDiff); + this.computeApproximateSize(false, currentDiff); const virtualizer = this.getSimpleVirtualizer(); this.top ??= this.getVirtualizedTop(); if (this.isAdvancedMode()) { @@ -1261,12 +1264,14 @@ export class VirtualizedFileDiff< this.top ??= this.getVirtualizedTop(); if (targetChanged) { this.getSimpleVirtualizer()?.markDOMDirty(); - this.computeApproximateSize(false, nextFileDiff); + this.computeApproximateSize(false, currentDiff); } } if (!this.isVisible && this.isSimpleMode() && (!dataChanged || !isSetup)) { - this.fileDiff = nextFileDiff; + if (targetChanged) { + this.fileDiff = nextFileDiff; + } if (fileInput != null) { this.deletionFile = oldFile; this.additionFile = newFile; @@ -1280,7 +1285,7 @@ export class VirtualizedFileDiff< const windowSpecs = this.virtualizer.getWindowSpecs(); const fileTop = this.top ?? 0; const renderRange = this.computeRenderRangeFromWindow( - nextFileDiff, + currentDiff, fileTop, windowSpecs ); @@ -1359,7 +1364,7 @@ export class VirtualizedFileDiff< } private approximateLayoutCheckpoints( - fileDiff: FileDiffMetadata | undefined = this.fileDiff + fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() ): void { if ( (!this.layoutDirty && this.cache.checkpoints.length > 0) || 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.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index 6ec7a535e..6a4a8c6c2 100644 --- a/packages/diffs/test/CodeView.edit.test.ts +++ b/packages/diffs/test/CodeView.edit.test.ts @@ -13,6 +13,7 @@ import type { DiffsEditableComponent, DiffsEditor, FileContents, + FileDiffMetadata, HighlightedToken, LineAnnotation, } from '../src/types'; @@ -99,6 +100,18 @@ function createEditorHarness({ return { editors, createEditor }; } +function getEditSessionDiff(instance: unknown): FileDiffMetadata | undefined { + return (instance as { editSessionDiff?: FileDiffMetadata }).editSessionDiff; +} + +function getRendererDiff(instance: unknown): FileDiffMetadata | undefined { + return ( + instance as { + hunksRenderer?: { diffCache?: FileDiffMetadata }; + } + ).hunksRenderer?.diffCache; +} + function makeEditFileItem( id: string, edit = true, @@ -1118,12 +1131,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 +1172,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 +1187,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 +1206,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,10 +1218,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'); + const remounted = viewer + .getRenderedItems() + .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/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts index bfd76262a..4ca9c3e67 100644 --- a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -94,6 +94,7 @@ async function createAttachedFixture(): Promise<{ cleanup(): void; detach(recycle?: boolean): void; externalDiff: FileDiffMetadata; + fileContainer: HTMLElement; instance: TestFileDiff; }> { const dom = installDom(); @@ -135,6 +136,7 @@ async function createAttachedFixture(): Promise<{ }, detach, externalDiff, + fileContainer, instance, }; } @@ -263,6 +265,150 @@ describe('FileDiff edit-session ownership', () => { } }); + 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.getCurrentDiffForTest(); + expect(sessionDiff).toBeDefined(); + expect(sessionDiff).not.toBe(externalDiff); + + triggerRender(fixture); + + expect(instance.getCurrentDiffForTest()).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.getCurrentDiffForTest(); + expect(sessionDiff).toBeDefined(); + + instance.render({ + fileDiff: equivalentExternalDiff, + fileContainer, + forceRender: true, + }); + + expect(instance.fileDiff).toBe(externalDiff); + expect(instance.getCurrentDiffForTest()).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.getCurrentDiffForTest(); + 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; 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.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 c98d8eab4..2dbf85ce8 100644 --- a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts +++ b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts @@ -74,7 +74,7 @@ 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; @@ -101,11 +101,17 @@ describe('FileDiff unified edit separators', () => { 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(); diff --git a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 440a173c5..86cbcc7df 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -603,4 +603,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.getCurrentDiffForTest(); + + instance.prepareCodeViewItem(equivalentDiff, 0); + instance.render({ + fileContainer, + fileDiff: equivalentDiff, + forceRender: true, + }); + + expect(instance.fileDiff).toBe(externalDiff); + expect(sessionDiff).not.toBe(externalDiff); + expect(instance.getCurrentDiffForTest()).toBe(sessionDiff); + expect(sessionDiff?.additionLines).toBe(externalDiff.additionLines); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); }); From dde67cb39ffa2c807031ce280431bf67373c09e1 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sun, 9 Aug 2026 02:18:36 -0700 Subject: [PATCH 06/25] Phase 2.5: Separate external diff from edited diff --- packages/diffs/src/components/File.ts | 1 + packages/diffs/src/components/FileDiff.ts | 13 +- packages/diffs/src/editor/editor.ts | 44 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 149 +++-- packages/diffs/src/types.ts | 1 + .../diffs/src/utils/areDiffTargetsEqual.ts | 7 +- .../test/CodeView.partialHydration.test.ts | 13 +- .../test/FileDiff.partialHydration.test.ts | 16 +- ...rtualizedFileDiff.partialHydration.test.ts | 13 +- packages/diffs/test/editorClipboard.test.ts | 1 + .../test/editorPersistStateLifecycle.test.ts | 14 +- packages/diffs/test/editorRecycle.test.ts | 5 +- packages/diffs/test/editorState.test.ts | 1 + .../test/editorVirtualizedReveal.test.ts | 1 + packages/diffs/test/editorWorkerPool.test.ts | 509 ++++++++++++++++-- packages/diffs/test/testUtils.ts | 18 + 16 files changed, 624 insertions(+), 182 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 8c25f526c..20494cca5 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -548,6 +548,7 @@ export class File< highlighter, fileContainer, file, + file.cacheKey, lineAnnotations, renderRange ); diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 3f5b2cc02..b5be14239 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1011,7 +1011,7 @@ export class FileDiff< } const editSessionDiff = createEditSessionDiff(expectedDiff); this.editSessionDiff = editSessionDiff; - this.hunksRenderer.beginEditSession(editSessionDiff); + this.hunksRenderer.beginEditSession(editSessionDiff, expectedDiff); return true; } @@ -1348,6 +1348,7 @@ export class FileDiff< highlighter, fileContainer, fileDiff, + this.fileDiff?.cacheKey, lineAnnotations, renderRange ); @@ -1418,14 +1419,16 @@ export class FileDiff< } this.editor?.cleanUp(); this.editor = editor; - if ( + const externalDiff = this.editSessionDiff == null && this.fileDiff != null && !this.fileDiff.isPartial - ) { - this.editSessionDiff = createEditSessionDiff(this.fileDiff); + ? this.fileDiff + : undefined; + if (externalDiff != null) { + this.editSessionDiff = createEditSessionDiff(externalDiff); } - this.hunksRenderer.beginEditSession(this.editSessionDiff); + this.hunksRenderer.beginEditSession(this.editSessionDiff, externalDiff); // 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) { diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index a92944813..b22763112 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -351,6 +351,7 @@ export class Editor implements DiffsEditor { // state #fileInstance?: DiffsEditableComponent; #fileInfo?: Omit; + #externalCacheKey?: string; #lineAnnotations?: DiffLineAnnotation[]; #textDocument?: TextDocument; #renderRange?: RenderRange; @@ -767,15 +768,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 @@ -838,7 +840,7 @@ export class Editor implements DiffsEditor { const languageId = file.lang ?? getFiletypeFromFileName(file.name); if ( fileInfo !== undefined && - (requirePersistedCacheKey(fileInfo) !== cacheKey || + (this.#externalCacheKey !== cacheKey || fileInfo.name !== file.name || this.#textDocument?.languageId !== languageId) ) { @@ -896,6 +898,7 @@ export class Editor implements DiffsEditor { highlighter: DiffsHighlighter, fileContainer: HTMLElement, fileOrDiff: FileContents | FileDiffMetadata, + externalCacheKey: string | undefined, lineAnnotations: DiffLineAnnotation[] | undefined, renderRange: RenderRange | undefined ) => { @@ -952,21 +955,21 @@ 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. + // file, language, or external cache key) versus reusing the existing one. + // The render model may be a private keyless edit-session value, so its + // cacheKey cannot also identify the external persistence target. const shouldRebuildDocument = this.#textDocument === undefined || this.#fileInfo === undefined || this.#fileInfo.name !== fileOrDiff.name || this.#fileInfo.lang !== fileOrDiff.lang || - this.#fileInfo.cacheKey !== fileOrDiff.cacheKey; + this.#externalCacheKey !== externalCacheKey; const persistedCacheKey = this.#options.persistState === true - ? requirePersistedCacheKey(fileOrDiff) + ? requirePersistedCacheKey({ + name: fileOrDiff.name, + cacheKey: externalCacheKey, + }) : undefined; let persistedStateTarget: @@ -1001,6 +1004,7 @@ 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); @@ -1044,7 +1048,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({ @@ -1236,7 +1241,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; @@ -1332,7 +1340,7 @@ export class Editor implements DiffsEditor { this.#selections !== selections || currentView?.scrollLeft !== view?.scrollLeft || this.#fileInfo === undefined || - requirePersistedCacheKey(this.#fileInfo) !== cacheKey + this.#externalCacheKey !== cacheKey ) { return; } diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 1e937621c..12471165c 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -227,6 +227,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(); @@ -278,17 +281,19 @@ 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). The current session diff is reinstalled - * after renderer-cache resets so mutation paths cannot reach external data. - * An empty additions document gets one row for the editor's caret. + * 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(diff?: FileDiffMetadata): void { + public beginEditSession( + diff?: FileDiffMetadata, + externalDiff?: FileDiffMetadata + ): void { + const { editSessionActive: wasAlreadyActive, renderCache } = this; this.editSessionActive = true; if (diff != null) { this.diff = diff; - if (this.renderCache != null) { - this.renderCache.diff = diff; - } } const currentDiff = diff ?? this.diffCache; @@ -303,6 +308,54 @@ export class DiffHunksRenderer { ); 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 { result } = renderCache; + if ( + !renderCache.highlighted || + result == null || + !areDiffRenderOptionsEqual(renderCache.options, options) + ) { + this.clearRenderCache(); + return; + } + if (renderCache.diff === diff) { + return; + } + if ( + externalDiff != null && + areDiffTargetsEqual(renderCache.diff, externalDiff) + ) { + // 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, + }; + } else { + this.clearRenderCache(); } } @@ -326,23 +379,23 @@ 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, + * 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 lands, 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 ( @@ -373,20 +426,23 @@ export class DiffHunksRenderer { 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, + diff: currentDiff, options: fresh.options, highlighted: true, result: fresh.result, @@ -400,17 +456,7 @@ 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); - } } public setOptions(options: DiffHunksRendererOptions): void { @@ -911,12 +957,13 @@ export class DiffHunksRenderer { } public renderDiff( - diff: FileDiffMetadata | undefined = this.renderCache?.diff, + diff: FileDiffMetadata | undefined = this.diff, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): HunksRenderResult | undefined { if (diff == null) { return undefined; } + this.diff = diff; const { expandUnchanged, collapsedContextThreshold } = this.getOptionsWithDefaults(); let { options, forceHighlight } = this.getRenderOptions(diff); @@ -952,12 +999,9 @@ 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. + // Keep completed highlighted content visible while its replacement is + // prepared. `highlightPending` covers the narrower case where the cache + // has no result, so the host must retain its existing DOM instead. const highlightPending = this.renderCache.result == null && this.renderCache.highlighted && @@ -1046,11 +1090,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); }); } @@ -1068,6 +1107,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); } @@ -1177,21 +1217,27 @@ 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) + ) { + return; + } + const { options: currentOptions } = this.getRenderOptions(currentDiff); + if (!areDiffRenderOptionsEqual(currentOptions, options)) { return; } const triggerRenderUpdate = - this.renderCache.highlightPending === true || - !this.renderCache.highlighted || - !areDiffRenderOptionsEqual(this.renderCache.options, options) || - !areDiffTargetsEqual(this.renderCache.diff, diff); + renderCache.highlightPending === true || + !renderCache.highlighted || + !areDiffRenderOptionsEqual(renderCache.options, options) || + !areDiffTargetsEqual(renderCache.diff, currentDiff); this.renderCache = { - diff, + diff: currentDiff, options, highlighted, result, @@ -1252,7 +1298,6 @@ export class DiffHunksRenderer { } = this.getOptionsWithDefaults(); const isRenderCacheDirty = this.renderCache?.isDirty ?? false; - this.diff = fileDiff; const unified = diffStyle === 'unified'; const canHydrateContext = canHydrateCollapsedContext( fileDiff, @@ -1786,7 +1831,7 @@ export class DiffHunksRenderer { themeStyles, baseThemeType, headerElement: !disableFileHeader - ? this.renderHeader(this.diff) + ? this.renderHeader(fileDiff) : undefined, totalLines, rowCount: context.rowCount, diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index a9c0fab09..f842d8abd 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1133,6 +1133,7 @@ export interface DiffsEditor { highlighter: DiffsHighlighter, fileContainer: HTMLElement, fileOrDiff: FileContents | FileDiffMetadata, + externalCacheKey: string | undefined, lineAnnotations: | LineAnnotation[] | DiffLineAnnotation[] 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/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/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index e78298178..ccfc2bfb3 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -16,7 +16,7 @@ import type { } from '../src/types'; import type { WorkerPoolManager } from '../src/worker'; import { installDom, wait } from './domHarness'; -import { assertDefined } from './testUtils'; +import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); @@ -70,20 +70,6 @@ function makeDirtyLines( return new Map(edits.map(([line, text]) => [line, [[0, '', text]]])); } -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 createPrimeWorkerManager(): { primeDeferred: ReturnType>; primedDiffs: FileDiffMetadata[]; diff --git a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 86cbcc7df..7bb4cba51 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -6,7 +6,7 @@ import { VirtualizedFileDiff } from '../src/components/VirtualizedFileDiff'; import type { Virtualizer } from '../src/components/Virtualizer'; 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(); @@ -36,17 +36,6 @@ function createEditorStub(): DiffsEditor { }; } -function createDeferred(): { - promise: Promise; - resolve(value: T): void; -} { - let resolve: (value: T) => void = () => {}; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - return { promise, resolve }; -} - function createVirtualizer(visible = true): { virtualizer: Virtualizer; instanceChangedCalls: { layoutDirty: boolean }[]; diff --git a/packages/diffs/test/editorClipboard.test.ts b/packages/diffs/test/editorClipboard.test.ts index 6d41ffd3c..de990dd84 100644 --- a/packages/diffs/test/editorClipboard.test.ts +++ b/packages/diffs/test/editorClipboard.test.ts @@ -209,6 +209,7 @@ class TestEditableComponent implements DiffsEditableComponent { createTestHighlighter(), this.fileContainer, this.#file, + this.#file.cacheKey, this.#lineAnnotations, this.#renderRange ); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 896b5b917..63861b96d 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -7,6 +7,7 @@ import { Editor, type IStateStorage } from '../src/edit'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import type { EditorState, FileContents } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; +import { createDeferred } from './testUtils'; afterAll(async () => { await disposeHighlighter(); @@ -23,19 +24,6 @@ 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 }; -} - async function attachFile( editor: Editor, fileContents: FileContents diff --git a/packages/diffs/test/editorRecycle.test.ts b/packages/diffs/test/editorRecycle.test.ts index 371bc40ad..70e58937f 100644 --- a/packages/diffs/test/editorRecycle.test.ts +++ b/packages/diffs/test/editorRecycle.test.ts @@ -148,6 +148,7 @@ class TestEditableComponent implements DiffsEditableComponent { createTestHighlighter(), this.fileContainer, this.#file, + this.#file.cacheKey, this.#lineAnnotations, this.#renderRange ); @@ -268,10 +269,12 @@ describe('Editor onAttach lifecycle', () => { await wait(0); expect(onAttach).not.toHaveBeenCalled(); + const file = createFile(); editor.__syncRenderView( createTestHighlighter(), component.fileContainer, - createFile(), + file, + file.cacheKey, undefined, undefined ); diff --git a/packages/diffs/test/editorState.test.ts b/packages/diffs/test/editorState.test.ts index fdb1a3977..13a123d3d 100644 --- a/packages/diffs/test/editorState.test.ts +++ b/packages/diffs/test/editorState.test.ts @@ -116,6 +116,7 @@ class TestEditableComponent implements DiffsEditableComponent { createTestHighlighter(), this.fileContainer, this.file, + this.file.cacheKey, this.#lineAnnotations, this.#renderRange ); diff --git a/packages/diffs/test/editorVirtualizedReveal.test.ts b/packages/diffs/test/editorVirtualizedReveal.test.ts index 693e0e293..f04b767bb 100644 --- a/packages/diffs/test/editorVirtualizedReveal.test.ts +++ b/packages/diffs/test/editorVirtualizedReveal.test.ts @@ -108,6 +108,7 @@ class VirtualizedEditableComponent implements DiffsEditableComponent createTestHighlighter(), this.fileContainer, this.#file, + this.#file.cacheKey, undefined, { // Render only line 3 so the selected line 2 remains virtualized. diff --git a/packages/diffs/test/editorWorkerPool.test.ts b/packages/diffs/test/editorWorkerPool.test.ts index 2d6d45090..d9e10b383 100644 --- a/packages/diffs/test/editorWorkerPool.test.ts +++ b/packages/diffs/test/editorWorkerPool.test.ts @@ -13,11 +13,18 @@ import { } from '../src/highlighter/shared_highlighter'; import { DiffHunksRenderer } 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 { installDom, wait } from './domHarness'; +import { createDeferred, type Deferred } from './testUtils'; import { createInitializedManager, installAnimationFramePolyfill, @@ -34,8 +41,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 () => { @@ -86,7 +118,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', }); @@ -120,7 +152,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', }); @@ -161,7 +193,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, @@ -217,7 +249,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', }); @@ -236,7 +268,7 @@ describe('FileRenderer edit session', () => { } }); - test('a dirty edit session ends without resurrecting pre-edit pool markup', async () => { + test('ending an edit session preserves edited content instead of restoring the cached pre-edit render', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -283,7 +315,7 @@ describe('FileRenderer edit session', () => { } }); - 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', }); @@ -311,8 +343,118 @@ describe('FileRenderer edit session', () => { }); }); +describe('DiffHunksRenderer worker rendering', () => { + 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 + ); + const resultHtml = ( + result: ReturnType + ): string => + toHtml([ + ...(result?.unifiedContentAST ?? []), + ...(result?.additionsContentAST ?? []), + ...(result?.deletionsContentAST ?? []), + ]); + + 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 = resultHtml(settledA); + 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(resultHtml(whileBPending)).toBe(settledAHtml); + expect(resultHtml(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)); + + const settledBHtml = resultHtml(renderer.renderDiff(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(resultHtml(renderer.renderDiff(diffB))).toBe(settledBHtml); + } finally { + renderer.cleanUp(); + manager.terminate(); + } + }); +}); + 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 +465,7 @@ describe('DiffHunksRenderer edit session', () => { () => renderUpdates++, manager ); - const diff = parseDiffFromFile( + const externalDiff = parseDiffFromFile( { name: 'demo.ts', contents: 'const value = "old";\n', @@ -335,12 +477,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 +492,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 +505,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 +524,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 +536,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 +549,280 @@ 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); + respondToDiffRequest( + manager, + worker, + await withTimeout(worker.waitForDiffRequest()) + ); + await waitFor(() => { + expect(manager.getDiffResultCache(externalDiff)).toBeDefined(); + }); + 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 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); + + renderer.renderDiff(sessionDiff); + await waitFor(() => expect(renderer.editorRenderReady()).toBe(true)); + const result = renderer.renderDiff(sessionDiff); + 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); + + renderer.renderDiff(sessionDiff); + await waitFor(() => expect(renderer.editorRenderReady()).toBe(true)); + const result = renderer.renderDiff(sessionDiff); + 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 waitFor(() => { + 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 restores the worker markup', async () => { const dom = installDom(); const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', @@ -467,7 +883,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 +950,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 +964,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 +1015,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', @@ -684,7 +1095,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 +1146,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 +1199,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 +1217,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 +1250,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 +1294,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 +1343,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/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( From 1d5db8654e5cc08dc17a526f64ef3ad443240e19 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 00:01:48 -0700 Subject: [PATCH 07/25] Phase 2.6: Harden latest vs rendered diff Something of a sidequest, but the main thing here was to really harden how an external vs rendered diff is handled, and ensure especially in virtualized environments that we don't create really bad layout mismatches. --- packages/diffs/src/components/CodeView.ts | 9 +- packages/diffs/src/components/FileDiff.ts | 153 ++++-- .../diffs/src/components/VirtualizedFile.ts | 2 +- .../src/components/VirtualizedFileDiff.ts | 318 +++++++----- .../diffs/src/renderers/DiffHunksRenderer.ts | 220 +++++--- packages/diffs/src/types.ts | 3 - .../test/CodeView.workerRendering.test.ts | 469 ++++++++++++++++++ .../FileDiff.editSessionOwnership.test.ts | 32 +- .../test/FileDiff.partialHydration.test.ts | 12 +- .../test/FileDiff.workerRendering.test.ts | 177 +++++++ .../test/VirtualizedFile.fileSwap.test.ts | 4 +- ...rtualizedFileDiff.partialHydration.test.ts | 48 +- .../VirtualizedFileDiff.setVisibility.test.ts | 88 ++++ .../diffs/test/advancedStickySpecs.test.ts | 16 +- packages/diffs/test/editorWorkerPool.test.ts | 439 +++++++++++++++- .../test/sparseLayoutCheckpoints.test.ts | 38 +- .../test/virtualFileMetricsPadding.test.ts | 24 +- .../virtualizedApplyDocumentChange.test.ts | 4 +- ...irtualizedFileDiffEstimatedHeights.test.ts | 62 +-- .../virtualizedFilePersistedLayout.test.ts | 4 +- 20 files changed, 1758 insertions(+), 364 deletions(-) create mode 100644 packages/diffs/test/CodeView.workerRendering.test.ts create mode 100644 packages/diffs/test/FileDiff.workerRendering.test.ts create mode 100644 packages/diffs/test/VirtualizedFileDiff.setVisibility.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index e7c3e2d2d..94859a881 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -4015,15 +4015,14 @@ export class CodeView { } item.top = runningTop; if (item.type === 'diff') { - item.instance.consumeCodeViewLayoutChanges(item.item.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, @@ -4066,14 +4065,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/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index b5be14239..5ae11f573 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -246,6 +246,12 @@ type HydrationSetup = { lineAnnotations: DiffLineAnnotation[] | undefined; } & MaybeDiffFileInput; +interface HeaderCache { + lastRenderedHTML: string | undefined; + html: string | undefined; + fileDiff: FileDiffMetadata | undefined; +} + let instanceId = -1; export class FileDiff< @@ -296,11 +302,15 @@ export class FileDiff< protected additionFile?: FileContents | null; public fileDiff: FileDiffMetadata | undefined; private editSessionDiff: FileDiffMetadata | 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; @@ -363,9 +373,24 @@ export class FileDiff< lineNumber: number, side: SelectionSide = 'additions' ) => { - // Use the current diff so line indexes match what the component displays, - // including changes made during an active edit session. - const fileDiff = this.getCurrentDiff(); + 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; } @@ -441,7 +466,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? @@ -453,7 +478,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(); } @@ -689,9 +714,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; @@ -711,10 +736,10 @@ export class FileDiff< // Clean up the data this.fileDiff = undefined; this.editSessionDiff = undefined; + this.renderedDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; } - if (this.refreshViewTimeout != null) { clearTimeout(this.refreshViewTimeout); this.refreshViewTimeout = undefined; @@ -873,6 +898,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(); @@ -1118,24 +1144,25 @@ export class FileDiff< this.fileDiff = nextParsedFileDiff; } if (diffDidChange) { - this.cachedHeaderHTML = undefined; + this.clearReusableHeader(); } if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } - const currentDiff = this.getCurrentDiff(); - if (currentDiff == 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 ( - currentDiff.editSessionDirty === true && + latestDiff.editSessionDirty === true && this.shouldSelfHealEditSession() ) { - finishEditSessionForDiff(currentDiff, this.options.parseDiffOptions); + finishEditSessionForDiff(latestDiff, this.options.parseDiffOptions); void this.hunksRenderer.refreshHighlightedResult(); } if (expandUnchanged) { @@ -1154,7 +1181,7 @@ export class FileDiff< if (this.headerElement != null) { this.headerElement.remove(); this.headerElement = undefined; - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; } this.clearHeaderSlots(); } @@ -1170,7 +1197,7 @@ export class FileDiff< try { const hunksResult = this.hunksRenderer.renderDiff( - currentDiff, + latestDiff, EMPTY_RENDER_RANGE ); if (hunksResult != null) { @@ -1185,10 +1212,13 @@ export class FileDiff< this.applyHeaderToDOM( hunksResult.headerElement, fileContainer, - currentDiff + hunksResult.fileDiff ); } this.renderSeparators([]); + if (hunksResult != null) { + this.renderedDiff = hunksResult.fileDiff; + } this.injectUnsafeCSS(); } catch (error: unknown) { if (disableErrorHandling) { @@ -1213,10 +1243,13 @@ export class FileDiff< this.canPartiallyRender( forceRender, annotationsChanged, - filesDidChange || diffDidChange || themeChanged + filesDidChange || + diffDidChange || + themeChanged || + !areDiffTargetsEqual(this.renderedDiff, latestDiff) ) && this.applyPartialRender({ - fileDiff: currentDiff, + fileDiff: latestDiff, previousRenderRange, renderRange: nextRenderRange, }); @@ -1224,7 +1257,7 @@ export class FileDiff< // If we were unable to partially render, perform a full render if (!didPartiallyRender) { const hunksResult = this.hunksRenderer.renderDiff( - currentDiff, + latestDiff, nextRenderRange ); if (hunksResult == null) { @@ -1247,7 +1280,7 @@ export class FileDiff< this.applyHeaderToDOM( hunksResult.headerElement, fileContainer, - currentDiff + hunksResult.fileDiff ); } if ( @@ -1261,6 +1294,7 @@ export class FileDiff< this.pre = undefined; } this.renderSeparators(hunksResult.hunkData); + this.renderedDiff = hunksResult.fileDiff; } this.applyBuffers(pre, nextRenderRange); this.injectUnsafeCSS(); @@ -1317,16 +1351,24 @@ export class FileDiff< onPostRender?.(fileContainer, this, phase); } - protected getCurrentDiff( + // 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.getCurrentDiff(); + const fileDiff = this.getLatestDiff(); const lineAnnotations = this.lineAnnotations; const renderRange = this.computeEditorRenderRange(this.renderRange); if ( @@ -1340,7 +1382,7 @@ export class FileDiff< !this.enabled || this.editor !== editor || this.fileContainer !== fileContainer || - this.getCurrentDiff() !== fileDiff + this.getLatestDiff() !== fileDiff ) { return; } @@ -1365,7 +1407,7 @@ export class FileDiff< private computeEditorRenderRange( renderRange: RenderRange | undefined ): RenderRange | undefined { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLatestDiff(); if ( renderRange == null || fileDiff == null || @@ -1435,6 +1477,16 @@ export class FileDiff< 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 @@ -1472,7 +1524,7 @@ export class FileDiff< * ran. */ public completeEditSession(): boolean { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLatestDiff(); if (fileDiff == null || fileDiff.editSessionDirty !== true) { return false; } @@ -1587,7 +1639,7 @@ export class FileDiff< // (or will have on scroll) a rendered row under the current expansion // state. See isAdditionLineRenderable. public isLineRenderable(lineNumber: number): boolean { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); if (fileDiff == null) { return true; } @@ -1611,7 +1663,7 @@ export class FileDiff< lineNumber: number, direction: 'up' | 'down' ): number | undefined { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); if (fileDiff == null) { return lineNumber; } @@ -1636,7 +1688,7 @@ export class FileDiff< // Routed through expandHunk so subclass expansion flows (CodeView's // deferred pendingExpansions) apply. public revealLine(lineNumber: number): boolean { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); const { expandUnchanged = false, collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, @@ -1867,7 +1919,7 @@ export class FileDiff< this.unsafeCSSStyle = undefined; this.appliedUnsafeCSS = undefined; - this.lastRenderedHeaderHTML = undefined; + this.headerCache.lastRenderedHTML = undefined; this.lastRowCount = undefined; this.mounted = false; } @@ -1993,7 +2045,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) { @@ -2108,12 +2160,23 @@ export class FileDiff< this.placeHolder = undefined; // Session metadata changes in place, so an HTML cache created from the // external baseline cannot describe the current edit-session header. - const headerHTML = - fileDiff === this.editSessionDiff - ? toHtml(headerAST) - : (this.cachedHeaderHTML ?? toHtml(headerAST)); - this.cachedHeaderHTML = headerHTML; - if (headerHTML !== this.lastRenderedHeaderHTML) { + 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; @@ -2126,7 +2189,7 @@ export class FileDiff< container.shadowRoot?.prepend(newHeader); } this.headerElement = newHeader; - this.lastRenderedHeaderHTML = headerHTML; + this.headerCache.lastRenderedHTML = headerHTML; } if (this.isContainerManaged) { @@ -2182,6 +2245,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(); @@ -2626,6 +2694,7 @@ export class FileDiff< ); } rowCount += result.rowCount; + this.renderedDiff = result.fileDiff; }; this.cleanupErrorWrapper(); @@ -2684,7 +2753,7 @@ export class FileDiff< // fast refresh diff view via updating the `data-line-type` after an edit. // only for split view. private refreshSplitDiffView(): void { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLatestDiff(); if (this.options.diffStyle !== 'split' || fileDiff == null) { return; } @@ -2744,12 +2813,13 @@ 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 { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLatestDiff(); if (this.options.diffStyle !== 'unified' || fileDiff == null) { return; } @@ -2789,6 +2859,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..94ff0b6ca 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -315,7 +315,7 @@ export class VirtualizedFile< // 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( + public updateCodeViewLayout( file: FileContents, top: number, reset?: PendingCodeViewLayoutReset, diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 80beb7704..ff6a175f1 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.getCurrentDiff() - ): 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,7 @@ export class VirtualizedFileDiff< public reconcileHeights(): boolean { let hasHeightChange = false; const { overflow = 'scroll' } = this.options; - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); if (this.fileContainer == null || fileDiff == null) { if (this.height !== 0) { hasHeightChange = true; @@ -332,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; @@ -433,59 +440,104 @@ 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 { + pendingRenderDiff, + layoutDiffChanged, + renderedDiffChanged, + annotationsChanged, + } = this.updatePendingRender(fileDiff, lineAnnotations); + const diffChanged = !areDiffTargetsEqual(this.fileDiff, fileDiff); + 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 }); - } - if (targetChanged) { + if (diffChanged) { this.fileDiff = fileDiff; } + this.top = top; - this.computeApproximateSize(); + this.computeApproximateSize(false, pendingRenderDiff); return this.height; } @@ -493,12 +545,16 @@ export class VirtualizedFileDiff< lineNumber: number, side: SelectionSide = 'additions' ): { top: number; height: number } | undefined { - const fileDiff = this.getCurrentDiff(); + 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; } @@ -513,7 +569,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 @@ -625,7 +681,7 @@ export class VirtualizedFileDiff< public getNumericScrollAnchor( localViewportTop: number ): NumericScrollLineAnchor | undefined { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLayoutDiff(); if (fileDiff == null) { return undefined; } @@ -643,7 +699,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 ?? @@ -742,7 +798,7 @@ export class VirtualizedFileDiff< public getAdvancedStickySpecs( windowSpecs?: RenderWindow ): StickySpecs | undefined { - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getLayoutDiff(); if (this.top == null || fileDiff == null) { return undefined; } @@ -783,6 +839,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); } @@ -791,8 +852,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 = ( @@ -862,44 +927,6 @@ export class VirtualizedFileDiff< this.virtualizer.instanceChanged(this, true); } - public consumeCodeViewLayoutChanges( - expectedFileDiff: FileDiffMetadata - ): void { - let hasLayoutChange = false; - let didHydrate = false; - 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) { - Object.assign(expectedFileDiff, pendingHydratedDiff.nextDiff); - this.setHydratedState(pendingHydratedDiff.files); - this.startHydratedEditSession(expectedFileDiff); - didHydrate = true; - } - } - - if (didHydrate) { - this.forceRenderOverride = true; - this.resetLayoutCache({ includeEstimatedHeights: true }); - } else if (hasLayoutChange) { - this.forceRenderOverride = true; - this.invalidateDerivedLayoutCache(true); - } - } - protected override loadFilesIfNecessary(): void { if (this.pendingHydratedDiff != null) { if (this.pendingHydratedDiff.expectedDiff === this.fileDiff) { @@ -920,7 +947,7 @@ export class VirtualizedFileDiff< return true; } const { pendingExpansions } = this; - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); if ( pendingExpansions == null || pendingExpansions.length === 0 || @@ -1018,8 +1045,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. @@ -1041,7 +1076,7 @@ export class VirtualizedFileDiff< resetRenderRange: false, }); - const fileDiff = this.getCurrentDiff(); + const fileDiff = this.getRenderedDiff(); if (!this.isSimpleMode()) { this.computeApproximateSize(true); } else if ( @@ -1073,7 +1108,7 @@ export class VirtualizedFileDiff< // if the height is 100% accurate private computeApproximateSize( force = false, - fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { const shouldValidateSize = this.isResizeDebuggingEnabled(); if (!force && !this.layoutDirty && !shouldValidateSize) { @@ -1111,8 +1146,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.getCurrentDiff() + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): number { this.ensureEstimatedDiffHeights(fileDiff); const estimatedHeight = @@ -1128,7 +1169,7 @@ export class VirtualizedFileDiff< } private ensureEstimatedDiffHeights( - fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { if (fileDiff == null) { this.cache.estimatedSplitHeight = undefined; @@ -1165,7 +1206,7 @@ export class VirtualizedFileDiff< } private validateComputedHeight( - fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() + fileDiff: FileDiffMetadata | undefined = this.getLayoutDiff() ): void { if (this.fileContainer == null || fileDiff == null) { return; @@ -1220,16 +1261,9 @@ export class VirtualizedFileDiff< } const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = undefined; - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - if (annotationsChanged) { - this.resetLayoutCache({ includeEstimatedHeights: false }); - } const targetChanged = nextFileDiff != null && !areDiffTargetsEqual(this.fileDiff, nextFileDiff); const dataChanged = targetChanged || filesDidChange; - if (targetChanged) { - this.resetLayoutCache({ includeEstimatedHeights: true }); - } fileContainer = this.getOrCreateFileContainer(fileContainer); @@ -1239,10 +1273,38 @@ export class VirtualizedFileDiff< ); return false; } - const currentDiff = this.getCurrentDiff(nextFileDiff) ?? 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, currentDiff); + this.computeApproximateSize(false, pendingRenderDiff); const virtualizer = this.getSimpleVirtualizer(); this.top ??= this.getVirtualizedTop(); if (this.isAdvancedMode()) { @@ -1262,9 +1324,9 @@ export class VirtualizedFileDiff< this.isSetup = true; } else { this.top ??= this.getVirtualizedTop(); - if (targetChanged) { + if (layoutDiffChanged) { this.getSimpleVirtualizer()?.markDOMDirty(); - this.computeApproximateSize(false, currentDiff); + this.computeApproximateSize(false, pendingRenderDiff); } } @@ -1277,15 +1339,16 @@ export class VirtualizedFileDiff< 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( - currentDiff, + pendingRenderDiff, fileTop, windowSpecs ); @@ -1297,10 +1360,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 @@ -1312,6 +1384,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(); } @@ -1363,12 +1462,9 @@ export class VirtualizedFileDiff< return getOptionHunkSeparatorType(this.options.hunkSeparators); } - private approximateLayoutCheckpoints( - fileDiff: FileDiffMetadata | undefined = this.getCurrentDiff() - ): 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/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 12471165c..687bffc17 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; @@ -238,7 +250,10 @@ 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; // Edit-session state: while active, hunk updates go through the frozen // region skeleton (editSessionHunks) instead of the full recompute, and @@ -292,6 +307,9 @@ export class DiffHunksRenderer { ): void { const { editSessionActive: wasAlreadyActive, renderCache } = this; this.editSessionActive = true; + if (!wasAlreadyActive) { + this.pendingHighlightResult = undefined; + } if (diff != null) { this.diff = diff; } @@ -323,45 +341,45 @@ export class DiffHunksRenderer { 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) + !areDiffRenderOptionsEqual(renderCache.options, options) || + (!cacheBelongsToSession && !cacheBelongsToExternal) ) { this.clearRenderCache(); return; } - if (renderCache.diff === diff) { + if (cacheBelongsToSession) { return; } - if ( - externalDiff != null && - areDiffTargetsEqual(renderCache.diff, externalDiff) - ) { - // 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], - }, + + // 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, - }; - } else { - this.clearRenderCache(); - } + }, + renderRange: renderCache.renderRange, + }; } /** Leave edit-session mode. The exit recompute is the host's concern. */ public endEditSession(): void { this.editSessionActive = false; + this.pendingHighlightResult = undefined; } /** @@ -376,14 +394,13 @@ 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 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 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 { diff, renderCache, workerManager } = this; @@ -419,9 +436,9 @@ 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 @@ -441,12 +458,11 @@ export class DiffHunksRenderer { if (!areDiffRenderOptionsEqual(options, fresh.options)) { return; } - this.renderCache = { + this.pendingHighlightResult = { diff: currentDiff, options: fresh.options, highlighted: true, result: fresh.result, - renderRange: undefined, }; this.onRenderUpdate?.(); } @@ -457,6 +473,7 @@ export class DiffHunksRenderer { public clearRenderCache(): void { this.renderCache = undefined; + this.pendingHighlightResult = undefined; } public setOptions(options: DiffHunksRendererOptions): void { @@ -873,6 +890,7 @@ export class DiffHunksRenderer { } this.renderCache ??= { diff, + hydrated: true, highlighted: !massiveDiff && !isDiffPlainText(diff), options, result: massiveDiff ? undefined : cache?.result, @@ -956,24 +974,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.diff, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): HunksRenderResult | undefined { + this.diff = diff; if (diff == null) { + this.pendingHighlightResult = undefined; return undefined; } - this.diff = diff; 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; } @@ -990,6 +1071,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, @@ -999,20 +1081,17 @@ export class DiffHunksRenderer { !this.editSessionActive && this.workerManager?.isWorkingPool() === true ) { - // Keep completed highlighted content visible while its replacement is - // prepared. `highlightPending` covers the narrower case where the cache - // has no result, so the host must retain its existing DOM instead. - 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))) @@ -1064,6 +1143,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 || @@ -1230,22 +1310,22 @@ export class DiffHunksRenderer { return; } - const triggerRenderUpdate = - renderCache.highlightPending === true || + const triggerRender = + renderCache.result == null || !renderCache.highlighted || !areDiffRenderOptionsEqual(renderCache.options, options) || !areDiffTargetsEqual(renderCache.diff, currentDiff); + if (!triggerRender) { + return; + } - this.renderCache = { + this.pendingHighlightResult = { diff: currentDiff, options, highlighted, result, - renderRange: undefined, }; - if (triggerRenderUpdate) { - this.onRenderUpdate?.(); - } + this.onRenderUpdate?.(); } private getMatchingWorkerResultCache( @@ -1262,6 +1342,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 @@ -1813,6 +1918,7 @@ export class DiffHunksRenderer { ); return { + fileDiff, unifiedGutterAST: unified && hasContent ? context.unifiedGutterAST.children : undefined, unifiedContentAST, diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index f842d8abd..fa259a48d 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; } /** diff --git a/packages/diffs/test/CodeView.workerRendering.test.ts b/packages/diffs/test/CodeView.workerRendering.test.ts new file mode 100644 index 000000000..85b7bf7f8 --- /dev/null +++ b/packages/diffs/test/CodeView.workerRendering.test.ts @@ -0,0 +1,469 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; + +import { CodeView } from '../src/components/CodeView'; +import { + disposeHighlighter, + getSharedHighlighter, +} from '../src/highlighter/shared_highlighter'; +import type { + CodeViewItem, + DiffsHighlighter, + FileDiffMetadata, +} from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { renderDiffWithHighlighter } from '../src/utils/renderDiffWithHighlighter'; +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; +} + +describe('CodeView worker rendering', () => { + 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('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/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts index 4ca9c3e67..8e369bd7b 100644 --- a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -16,8 +16,8 @@ afterAll(async () => { }); class TestFileDiff extends FileDiff { - getCurrentDiffForTest(): FileDiffMetadata | undefined { - return this.getCurrentDiff(); + getLatestDiffForTest(): FileDiffMetadata | undefined { + return this.getLatestDiff(); } getRendererDiffForTest(): FileDiffMetadata | undefined { @@ -115,7 +115,7 @@ async function createAttachedFixture(): Promise<{ await waitFor( () => { - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); return ( sessionDiff != null && sessionDiff !== externalDiff && @@ -124,7 +124,7 @@ async function createAttachedFixture(): Promise<{ }, { timeout: 4_000 } ); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); expect(sessionDiff).not.toBe(externalDiff); expect(instance.isEditorRenderReadyForTest()).toBe(true); @@ -185,7 +185,7 @@ describe('FileDiff edit-session ownership', () => { const fixture = await createAttachedFixture(); const { detach, externalDiff, instance } = fixture; try { - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); if (sessionDiff == null) return; @@ -207,7 +207,7 @@ describe('FileDiff edit-session ownership', () => { const { detach, externalDiff, instance } = fixture; const externalBefore = captureExternalDiffState(externalDiff); try { - const sessionBefore = instance.getCurrentDiffForTest(); + const sessionBefore = instance.getLatestDiffForTest(); expect(sessionBefore).toBeDefined(); if (sessionBefore == null) return; expect(sessionBefore.additionLines).toBe(externalDiff.additionLines); @@ -218,7 +218,7 @@ describe('FileDiff edit-session ownership', () => { 'light' ); - const sessionAfter = instance.getCurrentDiffForTest(); + const sessionAfter = instance.getLatestDiffForTest(); expect(sessionAfter).toBe(sessionBefore); expect(sessionAfter?.additionLines).not.toBe(externalDiff.additionLines); expect(sessionAfter?.additionLines[1]).toBe('edited value\n'); @@ -239,7 +239,7 @@ describe('FileDiff edit-session ownership', () => { const { detach, externalDiff, instance } = fixture; const externalBefore = captureExternalDiffState(externalDiff); try { - const sessionBefore = instance.getCurrentDiffForTest(); + const sessionBefore = instance.getLatestDiffForTest(); expect(sessionBefore).toBeDefined(); if (sessionBefore == null) return; expect(sessionBefore.hunks).toBe(externalDiff.hunks); @@ -248,7 +248,7 @@ describe('FileDiff edit-session ownership', () => { makeTextDocument(['alpha\n', 'inserted\n', 'new value\n', 'omega\n']) ); - const sessionAfter = instance.getCurrentDiffForTest(); + const sessionAfter = instance.getLatestDiffForTest(); expect(sessionAfter).toBe(sessionBefore); expect(sessionAfter?.additionLines).not.toBe(externalDiff.additionLines); expect(sessionAfter?.additionLines.join('')).toBe( @@ -326,13 +326,13 @@ describe('FileDiff edit-session ownership', () => { makeDirtyLines([[1, 'edited value']]), 'light' ); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); expect(sessionDiff).not.toBe(externalDiff); triggerRender(fixture); - expect(instance.getCurrentDiffForTest()).toBe(sessionDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); expect(instance.getRendererDiffForTest()).toBe(sessionDiff); expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); expectExternalDiffUnchanged(instance, externalDiff, externalBefore); @@ -350,7 +350,7 @@ describe('FileDiff edit-session ownership', () => { const equivalentExternalDiff = structuredClone(externalDiff); const equivalentBefore = structuredClone(equivalentExternalDiff); try { - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); instance.render({ @@ -360,7 +360,7 @@ describe('FileDiff edit-session ownership', () => { }); expect(instance.fileDiff).toBe(externalDiff); - expect(instance.getCurrentDiffForTest()).toBe(sessionDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); expect(instance.getRendererDiffForTest()).toBe(sessionDiff); instance.updateRenderCache( @@ -386,7 +386,7 @@ describe('FileDiff edit-session ownership', () => { makeDirtyLines([[1, 'edited value']]), 'light' ); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); instance.render({ @@ -418,14 +418,14 @@ describe('FileDiff edit-session ownership', () => { makeDirtyLines([[1, 'edited value']]), 'light' ); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(sessionDiff).toBeDefined(); expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); detach(true); instance.cleanUp(true); - expect(instance.getCurrentDiffForTest()).toBe(sessionDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); expect(instance.getRendererDiffForTest()).toBeUndefined(); expectExternalDiffUnchanged(instance, externalDiff, externalBefore); } finally { diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index ccfc2bfb3..c909607ae 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -23,8 +23,8 @@ afterAll(async () => { }); class TestFileDiff extends FileDiff { - getCurrentDiffForTest() { - return this.getCurrentDiff(); + getLatestDiffForTest() { + return this.getLatestDiff(); } getRendererDiffForTest() { @@ -318,7 +318,7 @@ describe('FileDiff partial hydration', () => { }); detach = instance.attachEditor(createEditorStub()); - const partialSession = instance.getCurrentDiffForTest(); + 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. @@ -329,7 +329,7 @@ describe('FileDiff partial hydration', () => { deferred.resolve({ oldFile, newFile }); await loadPromise; - const hydratedSession = instance.getCurrentDiffForTest(); + const hydratedSession = instance.getLatestDiffForTest(); expect(hydratedSession).toBeDefined(); expect(hydratedSession).not.toBe(partial); expect(instance.fileDiff).toBe(partial); @@ -393,7 +393,7 @@ describe('FileDiff partial hydration', () => { deferred.resolve({ oldFile, newFile }); await loadPromise; - const hydratedSession = instance.getCurrentDiffForTest(); + const hydratedSession = instance.getLatestDiffForTest(); expect(hydratedSession).toBeDefined(); expect(hydratedSession).not.toBe(partial); expect(instance.fileDiff).toBe(partial); @@ -445,7 +445,7 @@ describe('FileDiff partial hydration', () => { deferred.resolve({ oldFile: null, newFile }); await loadPromise; - const hydratedSession = instance.getCurrentDiffForTest(); + const hydratedSession = instance.getLatestDiffForTest(); expect(hydratedSession).toBeDefined(); if (hydratedSession == null) return; expect(hydratedSession).not.toBe(partial); 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/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 7bb4cba51..26434788a 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -13,8 +13,8 @@ afterAll(async () => { }); class TestVirtualizedFileDiff extends VirtualizedFileDiff { - getCurrentDiffForTest() { - return this.getCurrentDiff(); + getLatestDiffForTest() { + return this.getLatestDiff(); } getExpandedHunkForTest(index: number) { @@ -179,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); @@ -250,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); @@ -301,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); @@ -366,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); @@ -389,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, @@ -421,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); @@ -434,7 +433,7 @@ describe('VirtualizedFileDiff partial hydration', () => { { layoutDirty: true }, ]); - instance.consumeCodeViewLayoutChanges(partial); + instance.updateCodeViewLayout(partial, 0); expect(instance.fileDiff).toBe(partial); expect(partial.isPartial).toBe(false); @@ -444,7 +443,6 @@ describe('VirtualizedFileDiff partial hydration', () => { 'keep 3\n', 'keep 4\n', ]); - instance.prepareCodeViewItem(partial, 0); expect(instance.fileDiff).toBe(partial); } finally { instance?.cleanUp(); @@ -469,7 +467,7 @@ describe('VirtualizedFileDiff partial hydration', () => { let detach: (() => void) | undefined; try { - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); const editor = createEditorStub(); detach = instance.attachEditor(editor); const loadPromise = instance.getPendingFileLoadPromiseForTest(); @@ -479,20 +477,20 @@ describe('VirtualizedFileDiff partial hydration', () => { await loadPromise; expect(partial.isPartial).toBe(true); - expect(instance.getCurrentDiffForTest()).toBe(partial); + expect(instance.getLatestDiffForTest()).toBe(partial); instance.cleanUp(true); detach = undefined; - instance.consumeCodeViewLayoutChanges(partial); + instance.updateCodeViewLayout(partial, 0); - expect(instance.getCurrentDiffForTest()).toBe(partial); + expect(instance.getLatestDiffForTest()).toBe(partial); expect(partial.isPartial).toBe(false); instance.virtualizedSetup(); - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); detach = instance.attachEditor(editor); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(instance.fileDiff).toBe(partial); expect(partial.isPartial).toBe(false); expect(partial.cacheKey).toBe('external:advanced-partial:hydrated'); @@ -525,7 +523,7 @@ describe('VirtualizedFileDiff partial hydration', () => { let detach: (() => void) | undefined; try { - instance.prepareCodeViewItem(partial, 0); + instance.updateCodeViewLayout(partial, 0); detach = instance.attachEditor(createEditorStub()); const loadPromise = instance.getPendingFileLoadPromiseForTest(); assertDefined(loadPromise, 'expected edit hydration to be pending'); @@ -533,7 +531,7 @@ describe('VirtualizedFileDiff partial hydration', () => { deferred.resolve({ oldFile, newFile }); await loadPromise; - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); expect(instance.fileDiff).toBe(partial); expect(partial.isPartial).toBe(false); expect(partial.cacheKey).toBe('external:simple-partial:hydrated'); @@ -612,9 +610,9 @@ describe('VirtualizedFileDiff partial hydration', () => { try { instance.render({ fileContainer, fileDiff: externalDiff }); detach = instance.attachEditor(createEditorStub()); - const sessionDiff = instance.getCurrentDiffForTest(); + const sessionDiff = instance.getLatestDiffForTest(); - instance.prepareCodeViewItem(equivalentDiff, 0); + instance.updateCodeViewLayout(equivalentDiff, 0); instance.render({ fileContainer, fileDiff: equivalentDiff, @@ -623,7 +621,7 @@ describe('VirtualizedFileDiff partial hydration', () => { expect(instance.fileDiff).toBe(externalDiff); expect(sessionDiff).not.toBe(externalDiff); - expect(instance.getCurrentDiffForTest()).toBe(sessionDiff); + expect(instance.getLatestDiffForTest()).toBe(sessionDiff); expect(sessionDiff?.additionLines).toBe(externalDiff.additionLines); } finally { detach?.(); 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/editorWorkerPool.test.ts b/packages/diffs/test/editorWorkerPool.test.ts index d9e10b383..241bef152 100644 --- a/packages/diffs/test/editorWorkerPool.test.ts +++ b/packages/diffs/test/editorWorkerPool.test.ts @@ -11,7 +11,10 @@ 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, @@ -23,6 +26,8 @@ import type { 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 { @@ -30,6 +35,7 @@ import { installAnimationFramePolyfill, respondToDiffRequest, respondToFileRequest, + type TestWorker, withTimeout, } from './workerPoolHarness'; @@ -96,6 +102,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. @@ -344,6 +414,48 @@ describe('FileRenderer edit session', () => { }); 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', @@ -390,15 +502,6 @@ describe('DiffHunksRenderer worker rendering', () => { sharedHighlighter, options ); - const resultHtml = ( - result: ReturnType - ): string => - toHtml([ - ...(result?.unifiedContentAST ?? []), - ...(result?.additionsContentAST ?? []), - ...(result?.deletionsContentAST ?? []), - ]); - renderer.renderDiff(diffA); const requestA = await withTimeout(worker.waitForDiffRequest()); worker.respond({ @@ -412,7 +515,8 @@ describe('DiffHunksRenderer worker rendering', () => { await waitFor(() => expect(renderUpdates).toBe(1)); const settledA = renderer.renderDiff(diffA); - const settledAHtml = resultHtml(settledA); + const settledAHtml = renderedDiffHtml(settledA); + expect(settledA?.fileDiff).toBe(diffA); expect(settledAHtml).toContain('alpha'); expect(renderer.diffCache).toBe(diffA); @@ -422,8 +526,9 @@ describe('DiffHunksRenderer worker rendering', () => { const requestB = await withTimeout(worker.waitForDiffRequest()); expect(requestB.diff.cacheKey).toBe(diffB.cacheKey); - expect(resultHtml(whileBPending)).toBe(settledAHtml); - expect(resultHtml(whileBPending)).not.toContain('beta'); + 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); @@ -437,7 +542,15 @@ describe('DiffHunksRenderer worker rendering', () => { }); await waitFor(() => expect(renderUpdates).toBe(1)); - const settledBHtml = resultHtml(renderer.renderDiff(diffB)); + // 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); @@ -445,12 +558,234 @@ describe('DiffHunksRenderer worker rendering', () => { renderer.onHighlightSuccess(diffA, highlightedA, options); expect(renderUpdates).toBe(1); expect(renderer.diffCache).toBe(diffB); - expect(resultHtml(renderer.renderDiff(diffB))).toBe(settledBHtml); + 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: 'pierre-light' }, + () => 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: ['pierre-light'], + 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: 'pierre-dark-soft' }, + () => 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: ['pierre-dark-soft'], + 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', () => { @@ -577,14 +912,17 @@ describe('DiffHunksRenderer edit session', () => { const sessionDiff = createKeylessSessionDiff(externalDiff); renderer.renderDiff(externalDiff); - respondToDiffRequest( + respondWithHighlightedDiff( manager, worker, - await withTimeout(worker.waitForDiffRequest()) + 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'); @@ -620,6 +958,53 @@ describe('DiffHunksRenderer edit session', () => { } }); + 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', @@ -656,9 +1041,13 @@ describe('DiffHunksRenderer edit session', () => { renderer.beginEditSession(sessionDiff, externalDiff); expect(renderer.editorRenderReady()).toBe(false); + const updatesBeforeSessionHighlight = renderUpdates; renderer.renderDiff(sessionDiff); - await waitFor(() => expect(renderer.editorRenderReady()).toBe(true)); + 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'); } @@ -726,9 +1115,13 @@ describe('DiffHunksRenderer edit session', () => { renderer.beginEditSession(sessionDiff, sessionExternalDiff); expect(renderer.editorRenderReady()).toBe(false); + const updatesBeforeSessionHighlight = renderUpdates; renderer.renderDiff(sessionDiff); - await waitFor(() => expect(renderer.editorRenderReady()).toBe(true)); + 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'); } @@ -781,10 +1174,10 @@ describe('DiffHunksRenderer edit session', () => { } sessionInitialization.resolve(sharedHighlighter); - await waitFor(() => { - expect(renderer.diffCache).toBe(sessionDiff); - expect(renderer.editorRenderReady()).toBe(true); - }); + await wait(0); + renderer.renderDiff(sessionDiff); + expect(renderer.diffCache).toBe(sessionDiff); + expect(renderer.editorRenderReady()).toBe(true); sessionDiff.additionLines = [...sessionDiff.additionLines]; renderer.updateRenderCache( 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/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..b6c3d8583 100644 --- a/packages/diffs/test/virtualizedApplyDocumentChange.test.ts +++ b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts @@ -74,7 +74,7 @@ describe('applyDocumentChange buffer updates', () => { {}, createStubVirtualizer('advanced') ); - advancedInstance.prepareCodeViewItem(makeFile(50), 0); + advancedInstance.updateCodeViewLayout(makeFile(50), 0); advancedInstance.seedRenderRange(seeded); advancedInstance.applyDocumentChange(makeDocument(1), undefined, true); expect(advancedInstance.bufferUpdates).toBe(0); @@ -83,7 +83,7 @@ describe('applyDocumentChange buffer updates', () => { {}, createStubVirtualizer('simple') ); - simpleInstance.prepareCodeViewItem(makeFile(50), 0); + simpleInstance.updateCodeViewLayout(makeFile(50), 0); simpleInstance.seedRenderRange(seeded); simpleInstance.applyDocumentChange(makeDocument(1), undefined, true); expect(simpleInstance.bufferUpdates).toBe(1); diff --git a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts index a501dea13..64e28c24b 100644 --- a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts +++ b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts @@ -286,7 +286,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 +304,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 +318,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 +356,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 +372,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 +388,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 +413,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 +438,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 +464,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { ); let measuredHeight = 17; - instance.prepareCodeViewItem(createTwoHunkDiff(), 0); + instance.updateCodeViewLayout(createTwoHunkDiff(), 0); inspect(instance).fileContainer = new FakeHTMLElement() as unknown as HTMLElement; inspect(instance).codeAdditions = createMeasuredCodeGroup( @@ -496,7 +496,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 +519,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 +540,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 +559,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 +581,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 +597,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 +616,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,7 +635,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); let annotationHeight = 25; - instance.prepareCodeViewItem(createTwoHunkDiff(), 0, undefined, [ + instance.updateCodeViewLayout(createTwoHunkDiff(), 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); inspect(instance).renderRange = createRenderRange(); @@ -676,7 +676,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { try { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); - instance.prepareCodeViewItem(createTwoHunkDiff(), 0, undefined, [ + instance.updateCodeViewLayout(createTwoHunkDiff(), 0, undefined, [ { side: 'additions', lineNumber: 0 }, ]); inspect(instance).renderRange = createRenderRange(); @@ -705,7 +705,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).renderRange = createRenderRange(); @@ -743,7 +743,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).renderRange = createRenderRange(); @@ -780,7 +780,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).renderRange = createRenderRange(); @@ -798,7 +798,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,7 +816,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).renderRange = createRenderRange(); @@ -836,9 +836,9 @@ describe('VirtualizedFileDiff estimated height cache', () => { inspect(instance).fileContainer = undefined; instance.cleanUp(true); - expect(inspect(instance).lineAnnotations).toHaveLength(1); + expect(inspect(instance).lineAnnotations).toHaveLength(0); - 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 +856,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 +876,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 +926,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 +958,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 ee4a767ad..1aba9f085 100644 --- a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts +++ b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts @@ -183,12 +183,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( + expect(instance.updateCodeViewLayout(file, 0)).toBe( headerHeight + 3 * metrics.lineHeight + paddingBottom ); }); From e0870b0392a82db203838ab0017838e19b58ffb6 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 14:28:39 -0700 Subject: [PATCH 08/25] Phase 2.7: Reconcile external updates during editing This was what we've been working towards, and it seems pretty great! --- packages/diffs/src/components/CodeView.ts | 6 +- packages/diffs/src/components/File.ts | 8 +- packages/diffs/src/components/FileDiff.ts | 144 ++++++- .../src/components/VirtualizedFileDiff.ts | 12 +- packages/diffs/src/editor/editStack.ts | 31 ++ packages/diffs/src/editor/editor.ts | 225 ++++++++-- packages/diffs/src/editor/textDocument.ts | 11 + packages/diffs/src/react/CodeView.tsx | 5 +- packages/diffs/src/types.ts | 41 +- packages/diffs/test/CodeView.edit.test.ts | 265 ++++++++++++ ...rtualizedFileDiff.partialHydration.test.ts | 62 +++ packages/diffs/test/editorClipboard.test.ts | 16 +- packages/diffs/test/editorRecycle.test.ts | 30 +- .../test/editorRenderUpdateLifecycle.test.ts | 406 ++++++++++++++++++ packages/diffs/test/editorState.test.ts | 16 +- .../test/editorVirtualizedReveal.test.ts | 18 +- 16 files changed, 1180 insertions(+), 116 deletions(-) create mode 100644 packages/diffs/test/editorRenderUpdateLifecycle.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 94859a881..98b894fcc 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, diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 20494cca5..f2c7525ae 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -544,14 +544,14 @@ export class File< ) { return; } - editor.__syncRenderView( + editor.__syncRenderView({ highlighter, fileContainer, file, - file.cacheKey, + externalCacheKey: file.cacheKey, lineAnnotations, - renderRange - ); + renderRange, + }); }); } } diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 5ae11f573..bd13262e7 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -84,6 +84,7 @@ import { } 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'; @@ -123,6 +124,25 @@ type DeferredEditorActiveLineWrite = [ options: EditorActiveLineOptions | undefined, ]; +interface PendingEditSessionReplacement { + /** + * The new caller-owned diff supplied to FileDiff. + * It may remain partial until its file contents finish loading. + */ + incomingExternalDiff: FileDiffMetadata; + /** + * 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; +} + function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { return ( fileDiff.isPartial && @@ -140,6 +160,29 @@ function createEditSessionDiff(fileDiff: FileDiffMetadata): FileDiffMetadata { 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); + if ( + prevDiff.name !== nextDiff.name || + prevLanguage !== nextLanguage || + 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; @@ -302,6 +345,9 @@ export class FileDiff< protected additionFile?: FileContents | null; public fileDiff: FileDiffMetadata | undefined; private editSessionDiff: FileDiffMetadata | undefined; + private pendingEditSessionReplacement: + | PendingEditSessionReplacement + | undefined; protected renderedDiff: FileDiffMetadata | undefined; protected renderRange: RenderRange | undefined; protected pendingFiles: PendingFileLoad | undefined; @@ -736,6 +782,7 @@ export class FileDiff< // Clean up the data this.fileDiff = undefined; this.editSessionDiff = undefined; + this.pendingEditSessionReplacement = undefined; this.renderedDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; @@ -1028,11 +1075,15 @@ export class FileDiff< 'FileDiff.startHydratedEditSession: diffs cannot be partial for editing' ); } - if ( - this.editor == null || - this.editSessionDiff != null || - this.fileDiff !== expectedDiff - ) { + if (this.fileDiff !== expectedDiff) { + return false; + } + const replacement = this.pendingEditSessionReplacement; + if (replacement?.incomingExternalDiff === expectedDiff) { + this.installExternalEditSession(expectedDiff); + return true; + } + if (this.editor == null || this.editSessionDiff != null) { return false; } const editSessionDiff = createEditSessionDiff(expectedDiff); @@ -1041,6 +1092,48 @@ export class FileDiff< 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; + if (editSessionDiff != null) { + this.pendingEditSessionReplacement = { + incomingExternalDiff, + editSessionDiff, + prevExternalCacheKey, + }; + if (incomingExternalDiff.isPartial) { + this.loadFilesIfNecessary(); + } else { + this.installExternalEditSession(incomingExternalDiff); + } + } + return true; + } + + private installExternalEditSession(externalDiff: FileDiffMetadata): void { + const sessionDiff = createEditSessionDiff(externalDiff); + this.editSessionDiff = sessionDiff; + this.hunksRenderer.beginEditSession(sessionDiff, externalDiff); + } + protected setHydratedState(files: LoadedPartialDiffContents): void { this.deletionFile = files.oldFile; this.additionFile = files.newFile; @@ -1138,10 +1231,10 @@ export class FileDiff< } if (fileDiff != null && diffDidChange) { - this.fileDiff = fileDiff; + this.updateExternalDiff(fileDiff); } else if (nextParsedFileDiff != null) { diffDidChange = true; - this.fileDiff = nextParsedFileDiff; + this.updateExternalDiff(nextParsedFileDiff); } if (diffDidChange) { this.clearReusableHeader(); @@ -1386,14 +1479,34 @@ export class FileDiff< ) { return; } - editor.__syncRenderView( + const { pendingEditSessionReplacement: replacement } = this; + const externalDocument = + replacement != null && + replacement.incomingExternalDiff === this.fileDiff && + replacement.editSessionDiff !== fileDiff; + const resetHistory = externalDocument + ? shouldResetUndoState( + replacement.editSessionDiff, + replacement.incomingExternalDiff + ) + : false; + const externalCacheKey = + replacement != null && !externalDocument + ? replacement.prevExternalCacheKey + : this.fileDiff?.cacheKey; + if (externalDocument) { + this.pendingEditSessionReplacement = undefined; + } + editor.__syncRenderView({ highlighter, fileContainer, fileDiff, - this.fileDiff?.cacheKey, + externalCacheKey, lineAnnotations, - renderRange - ); + renderRange, + externalDocument, + resetHistory, + }); }); } } @@ -1461,6 +1574,15 @@ export class FileDiff< } this.editor?.cleanUp(); this.editor = editor; + const pendingReplacement = this.pendingEditSessionReplacement; + if ( + pendingReplacement != null && + pendingReplacement.incomingExternalDiff === this.fileDiff && + !pendingReplacement.incomingExternalDiff.isPartial && + this.editSessionDiff === pendingReplacement.editSessionDiff + ) { + this.installExternalEditSession(pendingReplacement.incomingExternalDiff); + } const externalDiff = this.editSessionDiff == null && this.fileDiff != null && diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index ff6a175f1..39883bb22 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -489,13 +489,13 @@ export class VirtualizedFileDiff< } } + const diffChanged = this.updateExternalDiff(fileDiff); const { pendingRenderDiff, layoutDiffChanged, renderedDiffChanged, annotationsChanged, } = this.updatePendingRender(fileDiff, lineAnnotations); - const diffChanged = !areDiffTargetsEqual(this.fileDiff, fileDiff); if ( !this.forceRenderOverride && (diffChanged || renderedDiffChanged || annotationsChanged) @@ -532,10 +532,6 @@ export class VirtualizedFileDiff< this.invalidateDerivedLayoutCache(true); } - if (diffChanged) { - this.fileDiff = fileDiff; - } - this.top = top; this.computeApproximateSize(false, pendingRenderDiff); return this.height; @@ -1273,6 +1269,9 @@ export class VirtualizedFileDiff< ); return false; } + if (targetChanged) { + this.updateExternalDiff(nextFileDiff); + } const { pendingRenderDiff, @@ -1331,9 +1330,6 @@ export class VirtualizedFileDiff< } if (!this.isVisible && this.isSimpleMode() && (!dataChanged || !isSetup)) { - if (targetChanged) { - this.fileDiff = nextFileDiff; - } if (fileInput != null) { this.deletionFile = oldFile; this.additionFile = newFile; 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 b22763112..deeb76352 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -6,8 +6,8 @@ import type { DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, - DiffsHighlighter, EditableInstance, + EditorChange, EditorChangeEvent, EditorSelection, EditorState, @@ -246,7 +246,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,7 +355,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; @@ -894,14 +902,18 @@ export class Editor implements DiffsEditor { } /** @internal */ - __syncRenderView: DiffsEditor['__syncRenderView'] = ( - highlighter: DiffsHighlighter, - fileContainer: HTMLElement, - fileOrDiff: FileContents | FileDiffMetadata, - externalCacheKey: string | undefined, - 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 = isDiff && renderView.externalDocument === true; const fileInstance = this.#fileInstance; if (fileInstance == null) { return; @@ -954,16 +966,42 @@ export class Editor implements DiffsEditor { } } - // Whether this sync replaces the document with a freshly parsed one (a new - // file, language, or external cache key) versus reusing the existing one. - // The render model may be a private keyless edit-session value, so its - // cacheKey cannot also identify the external persistence target. + const languageId = + fileOrDiff.lang ?? getFiletypeFromFileName(fileOrDiff.name); + const contents = + 'contents' in fileOrDiff + ? fileOrDiff.contents + : fileOrDiff.additionLines.join(''); + const previousTextDocument = this.#textDocument; + const externalChanges = + externalDocument && + previousTextDocument !== undefined && + previousTextDocument.getText() !== contents + ? [ + { + start: 0, + end: previousTextDocument.getText().length, + text: contents, + range: { + start: { line: 0, character: 0 }, + end: previousTextDocument.positionAt( + previousTextDocument.getText().length + ), + }, + }, + ] + : undefined; + + // File renders retain their existing cache-key-driven lifecycle. FileDiff + // replacements provide the compatibility decision explicitly because a + // cache-key transition alone is not a document reset boundary. const shouldRebuildDocument = this.#textDocument === undefined || this.#fileInfo === undefined || this.#fileInfo.name !== fileOrDiff.name || - this.#fileInfo.lang !== fileOrDiff.lang || - this.#externalCacheKey !== externalCacheKey; + this.#textDocument.languageId !== languageId || + resetHistory || + (!externalDocument && this.#externalCacheKey !== externalCacheKey); const persistedCacheKey = this.#options.persistState === true ? requirePersistedCacheKey({ @@ -976,21 +1014,35 @@ export class Editor implements DiffsEditor { | { cacheKey: string; textDocument: TextDocument } | undefined; + if (externalDocument) { + this.#stateRestoreGeneration++; + this.#restoreStateOnNextSync = false; + 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 attachment restores cached text before painting (see @@ -1008,10 +1060,12 @@ export class Editor implements DiffsEditor { this.#textDocument = textDocument; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); - persistedStateTarget = { - cacheKey: persistedCacheKey, - textDocument, - }; + if (!externalDocument) { + persistedStateTarget = { + cacheKey: persistedCacheKey, + textDocument, + }; + } } this.#tokenizer?.cleanUp(); this.#tokenizer = undefined; @@ -1030,10 +1084,22 @@ export class Editor implements DiffsEditor { fileOrDiff.name ); } + } else if (isDiff && externalDocument) { + this.#applyExternalDocumentReplacement( + contents, + renderView.lineAnnotations + ); + 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 ( persistedStateTarget === undefined && + !externalDocument && this.#restoreStateOnNextSync && persistedCacheKey !== undefined && this.#textDocument !== undefined @@ -1135,7 +1201,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; @@ -1202,9 +1272,77 @@ export class Editor implements DiffsEditor { ); } + if (externalChanges != null) { + this.#emitChange(externalChanges, 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, cacheKey: string @@ -5396,16 +5534,10 @@ 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, - }); - } + this.#emitChange( + change.changes, + newLineAnnotations ?? this.#lineAnnotations + ); // Invalidate layout caches touched by the edit. Clear cached line Y // positions from startLine onward when either: @@ -5568,6 +5700,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/types.ts b/packages/diffs/src/types.ts index fa259a48d..9af98b808 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1118,6 +1118,35 @@ 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; +} + +export interface SyncDiffRenderViewProps< + LAnnotation, +> extends SyncRenderViewBaseProps { + fileDiff: FileDiffMetadata; + lineAnnotations: DiffLineAnnotation[] | undefined; + /** Treat the supplied contents as an externally provided document update. */ + externalDocument?: boolean; +} + +export type SyncRenderViewProps = + | SyncFileRenderViewProps + | SyncDiffRenderViewProps; + export interface DiffsEditor { /** @internal Persist the active File when key, name, or language changes. */ __persistFileState?(file: FileContents): string | undefined; @@ -1126,17 +1155,7 @@ export interface DiffsEditor { __postponeBgTokenizeToNextFrame(): void; /** @internal Capture focus intent before replacing the editable view. */ __captureFocusForDOMReplacement(): void; - __syncRenderView( - highlighter: DiffsHighlighter, - fileContainer: HTMLElement, - fileOrDiff: FileContents | FileDiffMetadata, - externalCacheKey: string | undefined, - lineAnnotations: - | LineAnnotation[] - | DiffLineAnnotation[] - | undefined, - renderRange: RenderRange | undefined - ): void; + __syncRenderView(props: SyncRenderViewProps): void; edit>( fileInstance: EditableInstance ): () => void; diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index 6a4a8c6c2..2f7ffaa30 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,11 +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, @@ -1035,6 +1038,268 @@ 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 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(); diff --git a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 26434788a..2aadac00a 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -505,6 +505,68 @@ describe('VirtualizedFileDiff partial hydration', () => { } }); + 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'; diff --git a/packages/diffs/test/editorClipboard.test.ts b/packages/diffs/test/editorClipboard.test.ts index de990dd84..53b50aed7 100644 --- a/packages/diffs/test/editorClipboard.test.ts +++ b/packages/diffs/test/editorClipboard.test.ts @@ -205,14 +205,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - this.#file.cacheKey, - 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/editorRecycle.test.ts b/packages/diffs/test/editorRecycle.test.ts index 70e58937f..2517f95d6 100644 --- a/packages/diffs/test/editorRecycle.test.ts +++ b/packages/diffs/test/editorRecycle.test.ts @@ -144,14 +144,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - this.#file.cacheKey, - 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 { @@ -270,14 +270,14 @@ describe('Editor onAttach lifecycle', () => { expect(onAttach).not.toHaveBeenCalled(); const file = createFile(); - editor.__syncRenderView( - createTestHighlighter(), - component.fileContainer, + editor.__syncRenderView({ + highlighter: createTestHighlighter(), + fileContainer: component.fileContainer, file, - file.cacheKey, - undefined, - undefined - ); + 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..73e7bd534 --- /dev/null +++ b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts @@ -0,0 +1,406 @@ +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, +}: { + cacheKey?: string; + name?: string; + oldContents?: string; + newContents: string; + lang?: SupportedLanguages; +}): FileDiffMetadata { + const diff = parseDiffFromFile( + { name, contents: oldContents }, + { name, contents: newContents } + ); + diff.cacheKey = cacheKey; + diff.lang = lang; + return diff; +} + +async function createFixture(options?: { + initialCacheKey?: string | null; + 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'), + newContents: 'alpha\n', + }); + 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, 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 13a123d3d..ad8d20669 100644 --- a/packages/diffs/test/editorState.test.ts +++ b/packages/diffs/test/editorState.test.ts @@ -112,14 +112,14 @@ class TestEditableComponent implements DiffsEditableComponent { ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.file, - this.file.cacheKey, - 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 f04b767bb..701e96a01 100644 --- a/packages/diffs/test/editorVirtualizedReveal.test.ts +++ b/packages/diffs/test/editorVirtualizedReveal.test.ts @@ -104,20 +104,20 @@ class VirtualizedEditableComponent implements DiffsEditableComponent ): void {} #syncRenderView(): void { - this.#editor?.__syncRenderView( - createTestHighlighter(), - this.fileContainer, - this.#file, - this.#file.cacheKey, - 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 { From bf364a443a86a98ec0f9919168a7dca133159b33 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 14:39:10 -0700 Subject: [PATCH 09/25] Sidequest 1: Fix persist error on homepage Cache keys were required there --- apps/docs/app/(diffs)/_home/mockData.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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`, + } ); } From 9d84eea7905528840bdf71a012e539bd4e4ffcd8 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 15:32:02 -0700 Subject: [PATCH 10/25] Sidequest 2: Prime workerpool caches for the AgentUi stuff --- apps/docs/app/(diffs)/_home/AgentUi.tsx | 48 ++++++++++++------------- 1 file changed, 22 insertions(+), 26 deletions(-) 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 = From aafc4501557563be3ed5dc56e3d5f5303c9ff310 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 16:00:25 -0700 Subject: [PATCH 11/25] Persisted Fix 1: Expose cached document contents --- packages/diffs/src/editor/editor.ts | 22 ++++++- packages/diffs/src/types.ts | 4 ++ .../test/editorPersistStateLifecycle.test.ts | 64 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index deeb76352..e23d1c7ff 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -12,7 +12,6 @@ import type { EditorSelection, EditorState, FileContents, - FileDiffMetadata, HighlightedToken, LineAnnotation, Position, @@ -858,6 +857,25 @@ export class Editor implements DiffsEditor { return cacheKey; } + /** @internal Return cached text only when it belongs to the same document. */ + __getCachedDocumentContents( + file: Pick + ): string | undefined { + if (this.#options.persistState !== true) { + return undefined; + } + + const cacheKey = requirePersistedCacheKey(file); + const textDocument = this.#getCachedTextDocument(file, cacheKey); + if ( + textDocument == null || + textDocument.uri !== new URL(file.name, 'file://').toString() + ) { + return undefined; + } + return textDocument.getText(); + } + /** @internal Return the File with cached document contents restored. */ __restoreCachedFile(file: FileContents): FileContents { const cacheKey = this.__persistFileState(file); @@ -1344,7 +1362,7 @@ export class Editor implements DiffsEditor { } #getCachedTextDocument( - file: FileContents | FileDiffMetadata, + file: Pick, cacheKey: string ): TextDocument | undefined { const textDocument = this.#textDocumentCache.get(cacheKey); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 9af98b808..645898e26 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1150,6 +1150,10 @@ export type SyncRenderViewProps = export interface DiffsEditor { /** @internal Persist the active File when key, name, or language changes. */ __persistFileState?(file: FileContents): string | undefined; + /** @internal Return cached text for the same persisted document identity. */ + __getCachedDocumentContents?( + file: Pick + ): string | undefined; /** @internal Return the File with cached document contents restored. */ __restoreCachedFile?(file: FileContents): FileContents; __postponeBgTokenizeToNextFrame(): void; diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 63861b96d..16ca8832f 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -379,6 +379,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 }); From bffe6a5f51691e3ad701180cc0378c62bbdb899a Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 16:14:51 -0700 Subject: [PATCH 12/25] Persisted Fix 2: Initialize edit session from cached contents --- packages/diffs/src/components/FileDiff.ts | 52 +++++++++++++++-- .../test/FileDiff.partialHydration.test.ts | 56 ++++++++++++++++++- .../test/editorPersistStateLifecycle.test.ts | 55 ++++++++++++------ 3 files changed, 140 insertions(+), 23 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index bd13262e7..d58a6a024 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -81,6 +81,7 @@ import { captureExpansionAnchors, finishEditSessionForDiff, rebuildExpansionFromAnchors, + rebuildSessionHunks, } from '../utils/editSessionHunks'; import { getDiffFileInput } from '../utils/getDiffFileInput'; import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions'; @@ -100,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, @@ -1083,12 +1085,11 @@ export class FileDiff< this.installExternalEditSession(expectedDiff); return true; } - if (this.editor == null || this.editSessionDiff != null) { + const { editor } = this; + if (editor == null || this.editSessionDiff != null) { return false; } - const editSessionDiff = createEditSessionDiff(expectedDiff); - this.editSessionDiff = editSessionDiff; - this.hunksRenderer.beginEditSession(editSessionDiff, expectedDiff); + this.createInitialEditSession(editor, expectedDiff); return true; } @@ -1134,6 +1135,44 @@ export class FileDiff< 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.editSessionDiff = editSessionDiff; + this.hunksRenderer.beginEditSession( + editSessionDiff, + restoredPersistedContents ? undefined : externalDiff + ); + } + protected setHydratedState(files: LoadedPartialDiffContents): void { this.deletionFile = files.oldFile; this.additionFile = files.newFile; @@ -1590,9 +1629,10 @@ export class FileDiff< ? this.fileDiff : undefined; if (externalDiff != null) { - this.editSessionDiff = createEditSessionDiff(externalDiff); + this.createInitialEditSession(editor, externalDiff); + } else { + this.hunksRenderer.beginEditSession(this.editSessionDiff); } - this.hunksRenderer.beginEditSession(this.editSessionDiff, externalDiff); // 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) { diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index c909607ae..841d98eab 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -54,11 +54,12 @@ class TestFileDiff extends FileDiff { } } -function createEditorStub(): DiffsEditor { +function createEditorStub(cachedContents?: string): DiffsEditor { return { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __getCachedDocumentContents: () => cachedContents, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, }; @@ -349,6 +350,59 @@ describe('FileDiff partial hydration', () => { } }); + 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, + }); + let detach: (() => void) | undefined; + + try { + instance.render({ + fileDiff: partial, + fileContainer, + forceRender: true, + }); + detach = instance.attachEditor(createEditorStub(cachedContents)); + + 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); + } finally { + detach?.(); + instance.cleanUp(); + cleanup(); + } + }); + test('recycle reuses an in-flight edit-session hydration request', async () => { const { cleanup } = installDom(); const { oldFile, newFile, partial } = createPartialChange( diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 16ca8832f..8f11d90ad 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -5,7 +5,8 @@ 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 { EditorState, FileContents, FileDiffMetadata } from '../src/types'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; import { installDom, wait, waitFor } from './domHarness'; import { createDeferred } from './testUtils'; @@ -24,6 +25,12 @@ interface AttachedFile { file: File; } +class TestFileDiff extends FileDiff { + getLatestDiffForTest(): FileDiffMetadata | undefined { + return this.getLatestDiff(); + } +} + async function attachFile( editor: Editor, fileContents: FileContents @@ -796,12 +803,7 @@ describe('Editor persisted state lifecycle', () => { } }); - // A FileDiff renders straight from the host's metadata (no - // __restoreCachedFile 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 container = document.createElement('div'); @@ -816,20 +818,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, }); @@ -850,15 +856,32 @@ describe('Editor persisted state lifecycle', () => { container.innerHTML = ''; 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'); - expect(editor.getText()).toBe('alpha\nbravo\n'); + await waitFor( + () => + editor.getText() === 'edited alpha\nbravo\n' && + container.shadowRoot?.querySelector('[data-content] [data-line="1"]') + ?.textContent === 'edited alpha' + ); + + 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); } finally { editor.cleanUp(); first.cleanUp(); From cb3ffcc12954881ca8419a7f176bef2ec66ec284 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 16:35:44 -0700 Subject: [PATCH 13/25] Persisted Fix 3: Ensure document properly re-uses history and emits --- packages/diffs/src/components/FileDiff.ts | 25 ++++ packages/diffs/src/editor/editor.ts | 47 ++++--- packages/diffs/src/types.ts | 5 + .../test/FileDiff.partialHydration.test.ts | 23 +++- .../test/editorPersistStateLifecycle.test.ts | 129 +++++++++++++++++- 5 files changed, 207 insertions(+), 22 deletions(-) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index d58a6a024..2d8c19a23 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -145,6 +145,11 @@ interface PendingEditSessionReplacement { prevExternalCacheKey: string | undefined; } +interface PendingPersistedDocumentRestore { + editSessionDiff: FileDiffMetadata; + previousContents: string; +} + function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { return ( fileDiff.isPartial && @@ -350,6 +355,9 @@ export class FileDiff< private pendingEditSessionReplacement: | PendingEditSessionReplacement | undefined; + private pendingPersistedDocumentRestore: + | PendingPersistedDocumentRestore + | undefined; protected renderedDiff: FileDiffMetadata | undefined; protected renderRange: RenderRange | undefined; protected pendingFiles: PendingFileLoad | undefined; @@ -785,6 +793,7 @@ export class FileDiff< this.fileDiff = undefined; this.editSessionDiff = undefined; this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; this.renderedDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; @@ -1114,6 +1123,7 @@ export class FileDiff< this.fileDiff = incomingExternalDiff; this.pendingEditSessionReplacement = undefined; + this.pendingPersistedDocumentRestore = undefined; if (editSessionDiff != null) { this.pendingEditSessionReplacement = { incomingExternalDiff, @@ -1164,6 +1174,12 @@ export class FileDiff< this.hunksRenderer.setExpandedHunksMap( rebuildExpansionFromAnchors(editSessionDiff, anchors) ); + this.pendingPersistedDocumentRestore = { + editSessionDiff, + previousContents: externalDiff.additionLines.join(''), + }; + } else { + this.pendingPersistedDocumentRestore = undefined; } this.editSessionDiff = editSessionDiff; @@ -1533,9 +1549,17 @@ export class FileDiff< 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, @@ -1545,6 +1569,7 @@ export class FileDiff< renderRange, externalDocument, resetHistory, + restoredDocument, }); }); } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index e23d1c7ff..0280609ef 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -21,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 { @@ -168,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' && @@ -932,6 +954,7 @@ export class Editor implements DiffsEditor { const fileOrDiff = isDiff ? renderView.fileDiff : renderView.file; const lineAnnotations = renderView.lineAnnotations; const externalDocument = isDiff && renderView.externalDocument === true; + const restoredDocument = isDiff ? renderView.restoredDocument : undefined; const fileInstance = this.#fileInstance; if (fileInstance == null) { return; @@ -991,24 +1014,14 @@ export class Editor implements DiffsEditor { ? fileOrDiff.contents : fileOrDiff.additionLines.join(''); const previousTextDocument = this.#textDocument; - const externalChanges = + const documentChanges = externalDocument && previousTextDocument !== undefined && previousTextDocument.getText() !== contents - ? [ - { - start: 0, - end: previousTextDocument.getText().length, - text: contents, - range: { - start: { line: 0, character: 0 }, - end: previousTextDocument.positionAt( - previousTextDocument.getText().length - ), - }, - }, - ] - : undefined; + ? [createFullDocumentChange(previousTextDocument.getText(), contents)] + : restoredDocument != null && restoredDocument !== contents + ? [createFullDocumentChange(restoredDocument, contents)] + : undefined; // File renders retain their existing cache-key-driven lifecycle. FileDiff // replacements provide the compatibility decision explicitly because a @@ -1290,8 +1303,8 @@ export class Editor implements DiffsEditor { ); } - if (externalChanges != null) { - this.#emitChange(externalChanges, lineAnnotations); + if (documentChanges != null) { + this.#emitChange(documentChanges, lineAnnotations); } this.#scheduleOnAttach(fileInstance); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 645898e26..31627cd4e 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1141,6 +1141,11 @@ export interface SyncDiffRenderViewProps< 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 = diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 841d98eab..5c3fbe67b 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -13,9 +13,10 @@ import type { FileDiffLoadedFiles, FileDiffMetadata, HighlightedToken, + SyncRenderViewProps, } from '../src/types'; import type { WorkerPoolManager } from '../src/worker'; -import { installDom, wait } from './domHarness'; +import { installDom, wait, waitFor } from './domHarness'; import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { @@ -369,6 +370,9 @@ describe('FileDiff partial hydration', () => { disableFileHeader: true, loadDiffFiles: () => deferred.promise, }); + const syncedViews: SyncRenderViewProps[] = []; + const editor = createEditorStub(cachedContents); + editor.__syncRenderView = (props) => syncedViews.push(props); let detach: (() => void) | undefined; try { @@ -377,7 +381,7 @@ describe('FileDiff partial hydration', () => { fileContainer, forceRender: true, }); - detach = instance.attachEditor(createEditorStub(cachedContents)); + detach = instance.attachEditor(editor); const loadPromise = instance.getPendingFileLoadPromiseForTest(); expect(loadPromise).toBeDefined(); @@ -396,6 +400,21 @@ describe('FileDiff partial hydration', () => { 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(); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 8f11d90ad..54e2e59d8 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -5,7 +5,12 @@ 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, FileDiffMetadata } 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'; @@ -805,7 +810,21 @@ describe('Editor persisted state lifecycle', () => { 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 = { @@ -851,9 +870,13 @@ 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({ fileDiff: externalDiff, @@ -865,7 +888,8 @@ describe('Editor persisted state lifecycle', () => { () => editor.getText() === 'edited alpha\nbravo\n' && container.shadowRoot?.querySelector('[data-content] [data-line="1"]') - ?.textContent === 'edited alpha' + ?.textContent === 'edited alpha' && + changes.length === 1 ); const restoredDiff = second.getLatestDiffForTest(); @@ -882,6 +906,105 @@ describe('Editor persisted state lifecycle', () => { 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(); From 9c939eed2a1c2dc89af4f057b87c256a582081c1 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 11 Aug 2026 16:52:25 -0700 Subject: [PATCH 14/25] Persisted Fix 4: Clean up some tests --- .../test/editorPersistStateLifecycle.test.ts | 113 +++++++++++++++++- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 54e2e59d8..833cdb6fc 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -738,10 +738,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 }); @@ -1013,6 +1012,112 @@ describe('Editor persisted state lifecycle', () => { } }); + 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(); + second.cleanUp(); + dom.cleanup(); + } + }); + test('without persistState, attaching a diff leaves the viewport alone', async () => { const dom = installDom(); const editor = new Editor(); From af1510bb024cdc63ae890ec610278bdc989bf000 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 00:51:13 -0700 Subject: [PATCH 15/25] Phase 2.8: Bring file inline with FileDiff workflow Also found a few areas where FileDiff did dumb shit that could also be cleaned up --- packages/diffs/src/components/File.ts | 335 ++++++++++++---- packages/diffs/src/components/FileDiff.ts | 150 ++++---- .../diffs/src/components/VirtualizedFile.ts | 185 ++++++--- packages/diffs/src/editor/editor.ts | 89 ++--- .../diffs/src/renderers/DiffHunksRenderer.ts | 10 +- packages/diffs/src/renderers/FileRenderer.ts | 362 ++++++++++++------ packages/diffs/src/types.ts | 10 +- .../diffs/src/utils/areFileTargetsEqual.ts | 14 + packages/diffs/test/CodeView.edit.test.ts | 133 ++++++- .../test/CodeView.workerRendering.test.ts | 140 +++++++ .../test/File.editSessionOwnership.test.ts | 302 +++++++++++++++ packages/diffs/test/FileRenderer.test.ts | 65 +++- packages/diffs/test/editorApplyEdits.test.ts | 58 +-- .../test/editorPersistStateLifecycle.test.ts | 25 -- packages/diffs/test/editorWorkerPool.test.ts | 243 ++++++++++-- .../virtualizedFilePersistedLayout.test.ts | 52 ++- 16 files changed, 1619 insertions(+), 554 deletions(-) create mode 100644 packages/diffs/src/utils/areFileTargetsEqual.ts create mode 100644 packages/diffs/test/File.editSessionOwnership.test.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index f2c7525ae..73ed9b14d 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,14 +214,18 @@ 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; protected editor: DiffsEditor | undefined; - // When an editor attaches before the first file render, defer persisted - // document restoration to that render. Clearing this afterward keeps later - // host renders authoritative instead of replacing them with cached content. - private deferPersistedRestore = false; constructor( public options: FileOptions = { theme: DEFAULT_THEMES }, @@ -216,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 { @@ -396,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; } @@ -507,6 +632,7 @@ export class File< return; } this.fileRenderer.hydrate(file); + this.renderedFile = file; this.renderAnnotations(); this.renderGutterUtility(); this.injectUnsafeCSS(); @@ -515,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) @@ -529,48 +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, - externalCacheKey: file.cacheKey, - 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(); - this.deferPersistedRestore = this.file == null; - const preparedFile = - this.file != null ? editor.__restoreCachedFile?.(this.file) : undefined; - 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 @@ -579,7 +727,6 @@ export class File< this.rerender(); } return () => { - this.deferPersistedRestore = false; this.editor = undefined; this.fileRenderer.endEditSession(); }; @@ -590,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); @@ -609,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, @@ -616,28 +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' - ); - } - - let { file } = props; - if (this.deferPersistedRestore) { - file = this.editor?.__restoreCachedFile?.(file) ?? file; - this.deferPersistedRestore = false; - } else { - this.editor?.__persistFileState?.(file); - } - return this.renderPreparedFile( - file === props.file ? props : { ...props, file } - ); - } - - // Attachment-only restoration may replace the file before this shared phase; - // ordinary attached renders reach it with the host's supplied file unchanged. - protected renderPreparedFile({ + public render({ file, fileContainer, forceRender = false, @@ -647,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(); @@ -660,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 && @@ -678,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) { @@ -710,7 +856,7 @@ export class File< try { const fileResult = this.fileRenderer.renderFile( - file, + latestFile, EMPTY_RENDER_RANGE ); if (fileResult != null) { @@ -722,7 +868,14 @@ export class File< ); } if (fileResult?.headerAST != null) { - this.applyHeaderToDOM(fileResult.headerAST, fileContainer); + this.applyHeaderToDOM( + fileResult.headerAST, + fileContainer, + fileResult.file + ); + } + if (fileResult != null) { + this.renderedFile = fileResult.file; } this.injectUnsafeCSS(); } catch (error: unknown) { @@ -746,11 +899,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()); @@ -764,9 +926,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); @@ -1152,15 +1319,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; } @@ -1410,10 +1578,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 2d8c19a23..9deb27e57 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -127,11 +127,6 @@ type DeferredEditorActiveLineWrite = [ ]; interface PendingEditSessionReplacement { - /** - * The new caller-owned diff supplied to FileDiff. - * It may remain partial until its file contents finish loading. - */ - incomingExternalDiff: FileDiffMetadata; /** * The private editable diff that was active before the new external diff * arrived. It remains active while a partial replacement is loading and is @@ -1089,8 +1084,7 @@ export class FileDiff< if (this.fileDiff !== expectedDiff) { return false; } - const replacement = this.pendingEditSessionReplacement; - if (replacement?.incomingExternalDiff === expectedDiff) { + if (this.pendingEditSessionReplacement != null) { this.installExternalEditSession(expectedDiff); return true; } @@ -1126,7 +1120,6 @@ export class FileDiff< this.pendingPersistedDocumentRestore = undefined; if (editSessionDiff != null) { this.pendingEditSessionReplacement = { - incomingExternalDiff, editSessionDiff, prevExternalCacheKey, }; @@ -1207,9 +1200,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 @@ -1218,6 +1208,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(); @@ -1514,65 +1508,62 @@ export class FileDiff< } private syncRenderViewToEditor(): void { - const editor = this.editor; - const fileContainer = this.fileContainer; - const fileDiff = this.getLatestDiff(); - 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.getLatestDiff() !== fileDiff - ) { - return; - } - const { pendingEditSessionReplacement: replacement } = this; - const externalDocument = - replacement != null && - replacement.incomingExternalDiff === this.fileDiff && - replacement.editSessionDiff !== fileDiff; - const resetHistory = externalDocument - ? shouldResetUndoState( - replacement.editSessionDiff, - replacement.incomingExternalDiff - ) - : 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, - }); - }); + 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 @@ -1638,23 +1629,26 @@ export class FileDiff< } this.editor?.cleanUp(); this.editor = editor; - const pendingReplacement = this.pendingEditSessionReplacement; + const { + fileDiff: externalDiff, + pendingEditSessionReplacement: pendingReplacement, + } = this; if ( pendingReplacement != null && - pendingReplacement.incomingExternalDiff === this.fileDiff && - !pendingReplacement.incomingExternalDiff.isPartial && + externalDiff != null && + !externalDiff.isPartial && this.editSessionDiff === pendingReplacement.editSessionDiff ) { - this.installExternalEditSession(pendingReplacement.incomingExternalDiff); + this.installExternalEditSession(externalDiff); } - const externalDiff = + const initialExternalDiff = this.editSessionDiff == null && - this.fileDiff != null && - !this.fileDiff.isPartial - ? this.fileDiff + externalDiff != null && + !externalDiff.isPartial + ? externalDiff : undefined; - if (externalDiff != null) { - this.createInitialEditSession(editor, externalDiff); + if (initialExternalDiff != null) { + this.createInitialEditSession(editor, initialExternalDiff); } else { this.hunksRenderer.beginEditSession(this.editSessionDiff); } @@ -1681,12 +1675,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(); } }; diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 94ff0b6ca..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. + // 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/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 0280609ef..a404facec 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -476,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) { @@ -507,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(); @@ -858,27 +849,6 @@ export class Editor implements DiffsEditor { this.#fileInstance = undefined; } - /** @internal Persist the active File when key, name, or language changes. */ - __persistFileState(file: FileContents): string | undefined { - if (this.#options.persistState !== true) { - return undefined; - } - - const cacheKey = requirePersistedCacheKey(file); - const fileInfo = this.#fileInfo; - const languageId = file.lang ?? getFiletypeFromFileName(file.name); - if ( - fileInfo !== undefined && - (this.#externalCacheKey !== cacheKey || - fileInfo.name !== file.name || - this.#textDocument?.languageId !== languageId) - ) { - this.#stateRestoreGeneration++; - this.#persistCurrentState(); - } - return cacheKey; - } - /** @internal Return cached text only when it belongs to the same document. */ __getCachedDocumentContents( file: Pick @@ -898,23 +868,6 @@ export class Editor implements DiffsEditor { return textDocument.getText(); } - /** @internal Return the File with cached document contents restored. */ - __restoreCachedFile(file: FileContents): FileContents { - const cacheKey = this.__persistFileState(file); - if (cacheKey === undefined) { - return file; - } - - const textDocument = this.#getCachedTextDocument(file, cacheKey); - if ( - textDocument === undefined || - textDocument.getText() === file.contents - ) { - return file; - } - return { ...file, contents: textDocument.getText() }; - } - /** @internal */ __postponeBgTokenizeToNextFrame(): void { const tokenizer = this.#tokenizer; @@ -953,8 +906,8 @@ export class Editor implements DiffsEditor { const isDiff = 'fileDiff' in renderView; const fileOrDiff = isDiff ? renderView.fileDiff : renderView.file; const lineAnnotations = renderView.lineAnnotations; - const externalDocument = isDiff && renderView.externalDocument === true; - const restoredDocument = isDiff ? renderView.restoredDocument : undefined; + const externalDocument = renderView.externalDocument === true; + const restoredDocument = renderView.restoredDocument; const fileInstance = this.#fileInstance; if (fileInstance == null) { return; @@ -1023,9 +976,9 @@ export class Editor implements DiffsEditor { ? [createFullDocumentChange(restoredDocument, contents)] : undefined; - // File renders retain their existing cache-key-driven lifecycle. FileDiff - // replacements provide the compatibility decision explicitly because a - // cache-key transition alone is not a document reset boundary. + // 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 || @@ -1046,8 +999,8 @@ export class Editor implements DiffsEditor { | undefined; if (externalDocument) { - this.#stateRestoreGeneration++; this.#restoreStateOnNextSync = false; + this.#stateRestoreGeneration++; this.#persistCurrentState(); if ( !shouldRebuildDocument && @@ -1076,8 +1029,8 @@ export class Editor implements DiffsEditor { !externalDocument && persistedCacheKey !== undefined ? this.#getCachedTextDocument(fileOrDiff, persistedCacheKey) : undefined; - // A File attachment restores cached text before painting (see - // __restoreCachedFile), 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 @@ -1091,7 +1044,7 @@ export class Editor implements DiffsEditor { this.#textDocument = textDocument; if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); - if (!externalDocument) { + if (!externalDocument || !isDiff) { persistedStateTarget = { cacheKey: persistedCacheKey, textDocument, @@ -1115,16 +1068,26 @@ export class Editor implements DiffsEditor { fileOrDiff.name ); } - } else if (isDiff && externalDocument) { + } else if (externalDocument) { this.#applyExternalDocumentReplacement( contents, - renderView.lineAnnotations + 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, + }; + } } } diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 687bffc17..815edaea6 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -1301,14 +1301,14 @@ export class DiffHunksRenderer { if ( currentDiff == null || renderCache == null || - !areDiffTargetsEqual(currentDiff, diff) + !areDiffTargetsEqual(currentDiff, diff) || + !areDiffRenderOptionsEqual( + options, + this.getRenderOptions(currentDiff).options + ) ) { return; } - const { options: currentOptions } = this.getRenderOptions(currentDiff); - if (!areDiffRenderOptionsEqual(currentOptions, options)) { - return; - } const triggerRender = renderCache.result == null || 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 31627cd4e..287fd87cc 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1023,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. @@ -1132,6 +1130,10 @@ export interface SyncFileRenderViewProps< > 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< @@ -1153,14 +1155,10 @@ export type SyncRenderViewProps = | SyncDiffRenderViewProps; export interface DiffsEditor { - /** @internal Persist the active File when key, name, or language changes. */ - __persistFileState?(file: FileContents): string | undefined; /** @internal Return cached text for the same persisted document identity. */ __getCachedDocumentContents?( file: Pick ): string | undefined; - /** @internal Return the File with cached document contents restored. */ - __restoreCachedFile?(file: FileContents): FileContents; __postponeBgTokenizeToNextFrame(): void; /** @internal Capture focus intent before replacing the editable view. */ __captureFocusForDOMReplacement(): void; 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/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index 2f7ffaa30..f0917a27f 100644 --- a/packages/diffs/test/CodeView.edit.test.ts +++ b/packages/diffs/test/CodeView.edit.test.ts @@ -107,6 +107,10 @@ 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 { @@ -700,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); @@ -718,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. @@ -726,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 { @@ -845,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); @@ -860,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); @@ -1209,6 +1222,98 @@ describe('CodeView item edit mode', () => { } }); + 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[] = []; diff --git a/packages/diffs/test/CodeView.workerRendering.test.ts b/packages/diffs/test/CodeView.workerRendering.test.ts index 85b7bf7f8..654d4a96d 100644 --- a/packages/diffs/test/CodeView.workerRendering.test.ts +++ b/packages/diffs/test/CodeView.workerRendering.test.ts @@ -8,10 +8,12 @@ import { 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, @@ -101,6 +103,10 @@ function getRenderedDiffForTest( 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('keeps layout matched to the displayed diff while its replacement is highlighted', async () => { const { cleanup } = installDom(); @@ -344,6 +350,140 @@ describe('CodeView worker rendering', () => { } }); + 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({ diff --git a/packages/diffs/test/File.editSessionOwnership.test.ts b/packages/diffs/test/File.editSessionOwnership.test.ts new file mode 100644 index 000000000..58a68d272 --- /dev/null +++ b/packages/diffs/test/File.editSessionOwnership.test.ts @@ -0,0 +1,302 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { disposeHighlighter, File } from '../src'; +import { Editor } from '../src/editor/editor'; +import type { FileContents } 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?: { onChange?(contents: string): 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) => options?.onChange?.(file.contents), + }); + + instance.render({ file: externalFile, fileContainer, forceRender: true }); + 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('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/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/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index a3ee9943f..3a06adcd8 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(); } }); @@ -369,7 +326,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 +403,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 +431,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/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 833cdb6fc..86bb9a3d7 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -98,31 +98,6 @@ function savedCaret(character: number): EditorState { } describe('Editor persisted state lifecycle', () => { - test('an attached render still validates the persistState cache key', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - const current = attached; - - expect(() => - current.file.render({ - file: { name: 'unkeyed.ts', contents: 'updated\n' }, - fileContainer: current.container, - forceRender: true, - }) - ).toThrow( - 'Editor persistState requires a non-empty file.cacheKey for "unkeyed.ts".' - ); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - test('an async restore survives an unchanged file rerender', async () => { const dom = installDom(); const pendingState = createDeferred(); diff --git a/packages/diffs/test/editorWorkerPool.test.ts b/packages/diffs/test/editorWorkerPool.test.ts index 241bef152..d6eb32919 100644 --- a/packages/diffs/test/editorWorkerPool.test.ts +++ b/packages/diffs/test/editorWorkerPool.test.ts @@ -87,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[] { @@ -205,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'); } @@ -238,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', @@ -250,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'); } @@ -280,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', @@ -293,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'); } @@ -329,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 { @@ -338,7 +348,7 @@ describe('FileRenderer edit session', () => { } }); - test('ending an edit session preserves edited content instead of restoring the cached pre-edit render', async () => { + test('ending an edit session preserves private edits without changing the external file', async () => { const { manager, worker } = await createInitializedManager({ theme: 'pierre-dark', }); @@ -361,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( @@ -372,13 +384,12 @@ 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(); @@ -396,21 +407,178 @@ 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', () => { @@ -648,7 +816,7 @@ describe('DiffHunksRenderer worker rendering', () => { test('keeps hydrated content selected until a local plain-text replacement is ready', async () => { let renderUpdates = 0; const renderer = new DeferredHighlighterDiffRenderer( - { theme: 'pierre-light' }, + { theme: 'andromeeda' }, () => renderUpdates++ ); try { @@ -670,7 +838,7 @@ describe('DiffHunksRenderer worker rendering', () => { expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); const replacementHighlighter = await getSharedHighlighter({ - themes: ['pierre-light'], + themes: ['andromeeda'], langs: ['typescript'], preferredHighlighter: 'shiki-js', }); @@ -695,7 +863,7 @@ describe('DiffHunksRenderer worker rendering', () => { test('keeps a hydrated plain-text diff selected until a local highlighted replacement is ready', async () => { let renderUpdates = 0; const renderer = new DeferredHighlighterDiffRenderer( - { theme: 'pierre-dark-soft' }, + { theme: 'ayu-dark' }, () => renderUpdates++ ); try { @@ -720,7 +888,7 @@ describe('DiffHunksRenderer worker rendering', () => { expect(renderer.getDiffForNextRender(diffB)).toBe(diffA); const replacementHighlighter = await getSharedHighlighter({ - themes: ['pierre-dark-soft'], + themes: ['ayu-dark'], langs: ['typescript'], preferredHighlighter: 'shiki-js', }); @@ -1215,7 +1383,7 @@ describe('DiffHunksRenderer edit session', () => { }); describe('File component edit session', () => { - test('attaching an editor switches to editor-compatible markup and detaching restores the worker markup', 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', @@ -1257,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(); @@ -1472,8 +1648,7 @@ describe('rendering when an editor attaches', () => { ); }); - // 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' diff --git a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts index 1aba9f085..330eb2e5a 100644 --- a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts +++ b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts @@ -40,6 +40,12 @@ const virtualizer = { const codeView = { type: 'advanced' } as never; +class TestVirtualizedFile extends VirtualizedFile { + getLatestFileForTest(): FileContents | undefined { + return this.getLatestFile(); + } +} + describe('VirtualizedFile persisted layout', () => { test('restores cached contents before computing approximate height', () => { const dom = installDom(); @@ -54,9 +60,9 @@ describe('VirtualizedFile persisted layout', () => { }; let restoreCalls = 0; const editor: DiffsEditor = { - __restoreCachedFile() { + __getCachedDocumentContents() { restoreCalls++; - return cachedFile; + 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 { @@ -76,7 +82,10 @@ describe('VirtualizedFile persisted layout', () => { }); expect(restoreCalls).toBe(1); - expect(instance.file?.contents).toBe(cachedFile.contents); + expect(instance.file).toBe(originalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + cachedFile.contents + ); expect(instance.getVirtualizedHeight()).toBe( getVirtualFileHeaderRegion(metrics, false) + 4 * metrics.lineHeight + @@ -103,17 +112,15 @@ describe('VirtualizedFile persisted layout', () => { const externalFile: FileContents = { ...originalFile, contents: 'external update', + cacheKey: 'file-v2', }; - const filesPassedToPersist: FileContents[] = []; let restoreCalls = 0; const editor: DiffsEditor = { - __persistFileState(file) { - filesPassedToPersist.push(file); - return file.cacheKey; - }, - __restoreCachedFile() { + __getCachedDocumentContents(file) { restoreCalls++; - return cachedFile; + return file.cacheKey === originalFile.cacheKey + ? cachedFile.contents + : undefined; }, __captureFocusForDOMReplacement() {}, __postponeBgTokenizeToNextFrame() {}, @@ -123,7 +130,7 @@ describe('VirtualizedFile persisted layout', () => { }, cleanUp() {}, }; - const instance = new VirtualizedFile({}, virtualizer, metrics); + const instance = new TestVirtualizedFile({}, virtualizer, metrics); const fileContainer = document.createElement('div'); let detach: (() => void) | undefined; @@ -131,12 +138,17 @@ describe('VirtualizedFile persisted layout', () => { instance.render({ file: originalFile, fileContainer }); detach = instance.attachEditor(editor); expect(restoreCalls).toBe(1); - expect(instance.file).toBe(cachedFile); + expect(instance.file).toBe(originalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + cachedFile.contents + ); instance.render({ file: externalFile, fileContainer }); expect(restoreCalls).toBe(1); - expect(filesPassedToPersist).toEqual([externalFile]); expect(instance.file).toBe(externalFile); + expect(instance.getLatestFileForTest()?.contents).toBe( + externalFile.contents + ); } finally { detach?.(); instance.cleanUp(); @@ -144,7 +156,7 @@ describe('VirtualizedFile persisted layout', () => { } }); - test('recomputes height when an unkeyed file is mutated in place', () => { + test('recomputes height for a new unkeyed file', () => { const dom = installDom(); const file: FileContents = { name: 'mutable.ts', @@ -161,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 + @@ -174,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', @@ -187,8 +199,8 @@ describe('VirtualizedFile persisted layout', () => { headerHeight + metrics.lineHeight + paddingBottom ); - file.contents = 'one\ntwo\nthree'; - expect(instance.updateCodeViewLayout(file, 0)).toBe( + const nextFile = { ...file, contents: 'one\ntwo\nthree' }; + expect(instance.updateCodeViewLayout(nextFile, 0)).toBe( headerHeight + 3 * metrics.lineHeight + paddingBottom ); }); From 91e7764ea808f027035986673da606f2d81d14fa Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 01:08:05 -0700 Subject: [PATCH 16/25] Phase 2.9: Fix annotation alignment sync bug --- packages/diffs/src/editor/editor.ts | 9 +++- packages/diffs/test/e2e/edit.pw.ts | 36 ++++++++++++++- packages/diffs/test/e2e/fixtures/edit.html | 51 ++++++++++++++++++---- 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index a404facec..4d1f992e2 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -3597,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) { 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 From a955313d811df630c4dae4203d7ac0e7fc2ea5d5 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 01:35:04 -0700 Subject: [PATCH 17/25] Playground Updates * Added File and Diff normal variants to playground * Added comment/line selection modes to normal Virtualizers --- .../(diffs)/playground/PlaygroundClient.tsx | 190 ++++++++++++++---- .../(diffs)/playground/PlaygroundCodeView.tsx | 4 +- .../PlaygroundVirtualizerElementView.tsx | 148 ++++++++++++-- .../playground/PlaygroundVirtualizerView.tsx | 128 +++++++++++- apps/docs/app/(diffs)/playground/constants.ts | 10 +- .../app/(diffs)/playground/searchParams.ts | 22 +- 6 files changed, 429 insertions(+), 73 deletions(-) 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')), }; From 9a04f5ec906dd48edd67614ca95f0c140d66701b Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 01:55:43 -0700 Subject: [PATCH 18/25] bugfix: fixes an issue with creating newlines before an annotation --- packages/diffs/src/editor/editor.ts | 13 ++-- .../test/File.editSessionOwnership.test.ts | 74 +++++++++++++++++-- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 4d1f992e2..4a29ccc98 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -5533,11 +5533,6 @@ export class Editor implements DiffsEditor { newLineAnnotations?: DiffLineAnnotation[], options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } ) { - this.#emitChange( - change.changes, - newLineAnnotations ?? this.#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 @@ -5660,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 && diff --git a/packages/diffs/test/File.editSessionOwnership.test.ts b/packages/diffs/test/File.editSessionOwnership.test.ts index 58a68d272..127476715 100644 --- a/packages/diffs/test/File.editSessionOwnership.test.ts +++ b/packages/diffs/test/File.editSessionOwnership.test.ts @@ -1,8 +1,8 @@ import { afterAll, describe, expect, test } from 'bun:test'; -import { disposeHighlighter, File } from '../src'; +import { disposeHighlighter, File, isFileAnnotationCollection } from '../src'; import { Editor } from '../src/editor/editor'; -import type { FileContents } from '../src/types'; +import type { FileContents, LineAnnotation } from '../src/types'; import { installDom, waitFor } from './domHarness'; afterAll(async () => { @@ -29,7 +29,13 @@ const EXTERNAL_FILE: FileContents = { cacheKey: 'external:file-v1', }; -async function createFixture(options?: { onChange?(contents: string): void }) { +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); @@ -39,10 +45,22 @@ async function createFixture(options?: { onChange?(contents: string): void }) { disableFileHeader: true, }); const editor = new Editor({ - onChange: (file) => options?.onChange?.(file.contents), + onChange(file, lineAnnotations) { + options?.onChange?.( + file.contents, + lineAnnotations == null || isFileAnnotationCollection(lineAnnotations) + ? lineAnnotations + : undefined + ); + }, }); - instance.render({ file: externalFile, fileContainer, forceRender: true }); + instance.render({ + file: externalFile, + fileContainer, + forceRender: true, + lineAnnotations: options?.lineAnnotations, + }); editor.edit(instance); await waitFor(() => editor.getText() === externalFile.contents, { timeout: 4_000, @@ -164,6 +182,52 @@ describe('editing a File without changing its input', () => { } }); + 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 { From 539f00330ad4fee562659057c130881e922da08b Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 02:19:00 -0700 Subject: [PATCH 19/25] bugfix: fix lines losing their syntax highlighting on newlines --- .../diffs/src/renderers/DiffHunksRenderer.ts | 130 ++++++++++-------- .../test/DiffHunksRendererRecompute.test.ts | 73 ++++++++-- 2 files changed, 129 insertions(+), 74 deletions(-) diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 815edaea6..e5f78645b 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -254,6 +254,9 @@ export class DiffHunksRenderer { // 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 @@ -474,6 +477,7 @@ export class DiffHunksRenderer { public clearRenderCache(): void { this.renderCache = undefined; this.pendingHighlightResult = undefined; + this.pendingStructuralRows = undefined; } public setOptions(options: DiffHunksRendererOptions): void { @@ -559,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; } @@ -571,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) { @@ -583,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: { @@ -621,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( @@ -680,7 +686,7 @@ export class DiffHunksRenderer { } result.baseThemeType = themeType; - this.renderCache.isDirty = true; + renderCache.isDirty = true; return regionsChanged; } @@ -695,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; } @@ -706,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, @@ -745,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 @@ -2485,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 @@ -2530,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/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([ From e90b45f72d10d73ce94608774017956e2511d718 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 11:17:09 -0700 Subject: [PATCH 20/25] tests: fix tests that were outdated in the new architecture --- packages/diffs/test/CodeView.edit.test.ts | 165 ------------------ packages/diffs/test/editorApplyEdits.test.ts | 49 ------ .../test/editorDiffEmptyDocument.test.ts | 22 ++- .../test/editorDisplayOptionResync.test.ts | 27 ++- .../diffs/test/editorFoldNavigation.test.ts | 17 +- packages/diffs/test/hydration.test.ts | 2 + .../virtualizedApplyDocumentChange.test.ts | 31 ++++ ...irtualizedFileDiffEstimatedHeights.test.ts | 28 ++- 8 files changed, 97 insertions(+), 244 deletions(-) diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index f0917a27f..9957552ae 100644 --- a/packages/diffs/test/CodeView.edit.test.ts +++ b/packages/diffs/test/CodeView.edit.test.ts @@ -1603,171 +1603,6 @@ describe('CodeView item edit mode', () => { 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(); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); }); describe('onItemEditComplete', () => { diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 3a06adcd8..58e8d057a 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -239,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[] = []; 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/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/virtualizedApplyDocumentChange.test.ts b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts index b6c3d8583..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 = { @@ -75,17 +97,26 @@ describe('applyDocumentChange buffer updates', () => { createStubVirtualizer('advanced') ); 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.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 64e28c24b..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, @@ -464,7 +474,9 @@ describe('VirtualizedFileDiff estimated height cache', () => { ); let measuredHeight = 17; - instance.updateCodeViewLayout(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( @@ -635,9 +647,11 @@ describe('VirtualizedFileDiff estimated height cache', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); let annotationHeight = 25; - instance.updateCodeViewLayout(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.updateCodeViewLayout(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; @@ -708,6 +724,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { 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; @@ -746,6 +763,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { 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; @@ -783,6 +801,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { 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; @@ -819,6 +838,7 @@ describe('VirtualizedFileDiff estimated height cache', () => { 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,8 +856,6 @@ describe('VirtualizedFileDiff estimated height cache', () => { inspect(instance).fileContainer = undefined; instance.cleanUp(true); - expect(inspect(instance).lineAnnotations).toHaveLength(0); - instance.updateCodeViewLayout(fileDiff, 0, undefined, []); expect(inspect(instance).cache.fileAnnotationHeight).toBe(0); From 6578bf6225d957cb4541867493b8f65133c72760 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 11:38:58 -0700 Subject: [PATCH 21/25] bugfix: ensure collapsed codeview items are properly update their instance files --- packages/diffs/src/components/File.ts | 4 +- packages/diffs/src/components/FileDiff.ts | 4 +- .../diffs/test/CodeView.collapsed.test.ts | 45 +++++++++++++++++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 73ed9b14d..c34e54afe 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -874,9 +874,7 @@ export class File< fileResult.file ); } - if (fileResult != null) { - this.renderedFile = fileResult.file; - } + this.renderedFile = fileResult?.file ?? latestFile; this.injectUnsafeCSS(); } catch (error: unknown) { if (disableErrorHandling) { diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 9deb27e57..b226c2bf9 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1358,9 +1358,7 @@ export class FileDiff< ); } this.renderSeparators([]); - if (hunksResult != null) { - this.renderedDiff = hunksResult.fileDiff; - } + this.renderedDiff = hunksResult?.fileDiff ?? latestDiff; this.injectUnsafeCSS(); } catch (error: unknown) { if (disableErrorHandling) { 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 ); From 1a81ead6c133344e89b0219d427a18436705e9ba Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 11:57:51 -0700 Subject: [PATCH 22/25] tests: fix up some tests to better align with the new architecture --- .../test/CodeView.elementPooling.test.ts | 5 ++ .../test/CodeView.workerRendering.test.ts | 62 ++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) 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.workerRendering.test.ts b/packages/diffs/test/CodeView.workerRendering.test.ts index 654d4a96d..9917cf7f3 100644 --- a/packages/diffs/test/CodeView.workerRendering.test.ts +++ b/packages/diffs/test/CodeView.workerRendering.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test'; import { CodeView } from '../src/components/CodeView'; import { @@ -108,6 +108,66 @@ function getRenderedFileForTest(instance: object): 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({ From 4a2b5254434970c316f0b953bf1f7d55a7b0ef41 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 12:13:39 -0700 Subject: [PATCH 23/25] bugfix: fix yet another test timing issue, that's not a real issue --- packages/diffs/src/components/CodeView.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 98b894fcc..f6f4fada3 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -3449,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); From dc81485fa7be08d2e5322adf2b2dbb262132e140 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 13:09:24 -0700 Subject: [PATCH 24/25] fix: properly handle empty vs new files as undo reset boundaries --- packages/diffs/src/components/FileDiff.ts | 3 + .../test/editorRenderUpdateLifecycle.test.ts | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index b226c2bf9..3ed105c1d 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -173,9 +173,12 @@ function shouldResetUndoState( } 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; diff --git a/packages/diffs/test/editorRenderUpdateLifecycle.test.ts b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts index 73e7bd534..d1ebd9c5e 100644 --- a/packages/diffs/test/editorRenderUpdateLifecycle.test.ts +++ b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts @@ -34,12 +34,14 @@ function createDiff({ 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 }, @@ -47,11 +49,14 @@ function createDiff({ ); 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; @@ -64,7 +69,9 @@ async function createFixture(options?: { 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, @@ -311,6 +318,70 @@ describe('external FileDiff updates during editing', () => { } }); + 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', From e08fb0db628ba059441ad53559dcd1bb5ab61690 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 12 Aug 2026 13:20:00 -0700 Subject: [PATCH 25/25] bugfix: regression for initial hydration replacements --- packages/diffs/src/components/FileDiff.ts | 2 + .../test/FileDiff.partialHydration.test.ts | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 3ed105c1d..9502c2ae6 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -1131,6 +1131,8 @@ export class FileDiff< } else { this.installExternalEditSession(incomingExternalDiff); } + } else if (this.editor != null && !incomingExternalDiff.isPartial) { + this.createInitialEditSession(this.editor, incomingExternalDiff); } return true; } diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 5c3fbe67b..9f23896c6 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -24,6 +24,10 @@ afterAll(async () => { }); class TestFileDiff extends FileDiff { + initializeHighlighterForTest() { + return this.hunksRenderer.initializeHighlighter(); + } + getLatestDiffForTest() { return this.getLatestDiff(); } @@ -351,6 +355,68 @@ describe('FileDiff partial hydration', () => { } }); + 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');