diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx index 6e0eff69bb0a5..d2c5acca4318a 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx @@ -1,11 +1,16 @@ import { + CodeView, + FileDiff, FileRenderer, getSharedHighlighter, + type CodeViewItem, type CodeViewScrollTarget, type FileContents, } from "@pierre/diffs"; import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react"; import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; +import { getDiffLineStat, getRenderablePatch } from "../../lib/diffRendering"; import { act, StrictMode, @@ -305,6 +310,157 @@ describe("code-view worker lifecycle", () => { }); }); +describe("collapsed diff content", () => { + let pool: WorkerPoolManager; + let container: HTMLDivElement; + let viewer: FileDiff | CodeView | undefined; + + const loadedDiff = () => { + const parsed = getRenderablePatch( + [ + "diff --git a/before.ts b/after.ts", + "similarity index 50%", + "rename from before.ts", + "rename to after.ts", + "--- a/before.ts", + "+++ b/after.ts", + "@@ -1,2 +1,2 @@", + " const shared = 1;", + "-const before = 2;", + "+const after = 3;", + ].join("\n"), + "collapsed-content", + ); + if (parsed?.kind !== "files" || !parsed.files[0]) throw new Error("Expected diff"); + return parsed.files[0]; + }; + const code = () => + [...container.querySelectorAll("diffs-container")] + .flatMap((element) => [...(element.shadowRoot?.querySelectorAll("[data-line]") ?? [])]) + .map((line) => line.textContent) + .join("\n"); + + beforeEach(async () => { + testState.requests = []; + testState.failWorkers = false; + testState.holdInitialization = false; + pool = new WorkerPoolManager( + { workerFactory: () => new DiffsWorker(), poolSize: 1 }, + { theme: "pierre-dark", langs: ["typescript"], preferredHighlighter: "shiki-wasm" }, + ); + await pool.initialize(); + container = document.createElement("div"); + container.style.cssText = "height: 400px; width: 800px; overflow: auto"; + document.body.append(container); + }); + + afterEach(() => { + viewer?.cleanUp(); + viewer = undefined; + pool.terminate(); + container.remove(); + vi.restoreAllMocks(); + }); + + it("defers plain AST and highlight work across collapsed renders until expansion", async () => { + const fileDiff = loadedDiff(); + const highlight = vi.spyOn(pool, "highlightDiffAST"); + const plainAST = vi.spyOn(pool, "getPlainDiffAST"); + const options = { theme: "pierre-dark", collapsed: true, disableErrorHandling: true } as const; + const file = new FileDiff(options, pool); + viewer = file; + file.render({ fileDiff, containerWrapper: container }); + file.render({ fileDiff, forceRender: true }); + expect.soft(highlight.mock.calls.length).toBe(0); + expect.soft(plainAST.mock.calls.length).toBe(0); + expect(code()).toBe(""); + expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( + "after.ts", + ); + + file.setOptions({ ...options, collapsed: false }); + file.render({ fileDiff, forceRender: true }); + expect(highlight).toHaveBeenCalled(); + await pool.primeDiffHighlightCache(fileDiff); + await vi.waitFor(() => expect(code()).toContain("const after = 3;")); + expect(code()).toContain("const before = 2;"); + expect(testState.requests.filter((request) => request.type === "diff")).toHaveLength(1); + }); + + it("finds newly loaded collapsed lines and unfolds the match with its annotation and header", async () => { + const fileDiff = loadedDiff(); + const item: CodeViewItem = { + type: "diff", + id: "renamed", + fileDiff, + collapsed: true, + annotations: [{ side: "additions", lineNumber: 2, metadata: "Review this change" }], + }; + const view = new CodeView( + { + theme: "pierre-dark", + disableErrorHandling: true, + onRevealSearchMatch: (match) => + view.setItems([{ ...match, collapsed: false, version: (match.version ?? 0) + 1 }]), + renderHeaderMetadata: (file) => { + if (!("hunks" in file)) return; + const metadata = document.createElement("span"); + const { additions, deletions } = getDiffLineStat([file]); + metadata.textContent = "+" + additions + " / -" + deletions; + return metadata; + }, + renderAnnotation: (annotation) => { + const note = document.createElement("span"); + note.textContent = annotation.metadata ?? ""; + return note; + }, + }, + pool, + ); + viewer = view; + view.setup(container); + view.openSearch(); + const search = container.querySelector("[data-diffs-search]")!.shadowRoot!; + const input = search.querySelector("input[data-search]")!; + input.value = "const after"; + input.dispatchEvent(new Event("input", { bubbles: true })); + view.setItems([item]); + view.render(true); + expect(code()).toBe(""); + expect(search.textContent).toContain("1 results"); + expect(container.textContent).toContain("+1 / -1"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + view.render(true); + await vi.waitFor(() => expect(code()).toContain("const after = 3;")); + expect(search.textContent).toContain("1 of 1"); + expect(container.textContent).toContain("Review this change"); + expect(container.textContent).toContain("+1 / -1"); + expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( + "after.ts", + ); + + await pool.primeDiffHighlightCache(fileDiff); + view.render(true); + const highlight = vi.spyOn(pool, "highlightDiffAST"); + const plainAST = vi.spyOn(pool, "getPlainDiffAST"); + const updatedDiff = { ...fileDiff, name: "updated.ts", cacheKey: "collapsed-update" }; + view.setItems([{ ...item, fileDiff: updatedDiff, version: 2 }]); + view.render(true); + expect(highlight).not.toHaveBeenCalled(); + expect(plainAST).not.toHaveBeenCalled(); + expect(code()).toBe(""); + expect(search.textContent).toContain("1 of 1"); + expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( + "updated.ts", + ); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + view.render(true); + await vi.waitFor(() => expect(code()).toContain("const after = 3;")); + expect(container.textContent).toContain("Review this change"); + }); +}); + type RevealHandle = NonNullable[0]>; const revealCalls: { scope: string; diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch index 6630f7394c214..a944c6355a2c7 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -694,6 +694,43 @@ index 0000000..29370ad +export { CodeViewSearch, collectMatches }; + +//# sourceMappingURL=CodeViewSearch.js.map +diff --git a/dist/components/FileDiff.js b/dist/components/FileDiff.js +index 6f66bd3..3125fac 100644 +--- a/dist/components/FileDiff.js ++++ b/dist/components/FileDiff.js +@@ -1,5 +1,6 @@ +-import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_RENDER_RANGE, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; ++import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; + import { getThemes } from "../utils/getThemes.js"; ++import { getHighlighterThemeStyles } from "../utils/getHighlighterThemeStyles.js"; + import { getHighlighterIfLoaded, getSharedHighlighter } from "../highlighter/shared_highlighter.js"; + import { dequeueRender, queueRender } from "../managers/UniversalRenderingManager.js"; + import { areThemesEqual } from "../utils/areThemesEqual.js"; +@@ -691,11 +692,20 @@ var FileDiff = class { + this.removeRenderedCode(); + this.clearAuxiliaryNodes(); + try { +- const hunksResult = this.hunksRenderer.renderDiff(latestDiff, EMPTY_RENDER_RANGE); +- if (hunksResult != null) this.applyThemeState(fileContainer, hunksResult.themeStyles, themeType, hunksResult.baseThemeType); +- if (hunksResult?.headerElement != null) this.applyHeaderToDOM(hunksResult.headerElement, fileContainer, hunksResult.fileDiff); ++ // An empty render range still highlights the full diff. Headers only need the theme. ++ const theme = this.getTheme(); ++ const highlighter = getHighlighterIfLoaded({ theme, lang: "text" }); ++ if (highlighter != null) this.applyThemeState(fileContainer, getHighlighterThemeStyles({ theme, highlighter }), themeType, typeof theme === "string" ? highlighter.getTheme(theme).type : void 0); ++ else getSharedHighlighter({ ++ themes: getThemes(theme), ++ langs: ["text"], ++ preferredHighlighter: this.workerManager?.getPreferredHighlighter() ?? this.options.preferredHighlighter ++ }).then(() => { ++ if (this.enabled && this.fileContainer === fileContainer && this.options.collapsed && areThemesEqual(theme, this.getTheme())) this.rerender(); ++ }).catch((error) => console.error(error)); ++ if (!disableFileHeader) this.applyHeaderToDOM(this.hunksRenderer.renderHeader(latestDiff), fileContainer, latestDiff); + this.renderSeparators([]); +- this.renderedDiff = hunksResult?.fileDiff ?? latestDiff; ++ this.renderedDiff = latestDiff; + this.injectUnsafeCSS(); + } catch (error) { + if (disableErrorHandling) throw error; diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts index 5e68e35..79dcd88 100644 --- a/dist/components/VirtualizedFile.d.ts @@ -773,6 +810,28 @@ index d1f57cd..5908ad7 100644 const file = this.getRenderedFile(); if (!this.isSimpleMode()) this.computeApproximateSize(true); else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/components/VirtualizedFileDiff.js b/dist/components/VirtualizedFileDiff.js +index 96f970f..1bb59c6 100644 +--- a/dist/components/VirtualizedFileDiff.js ++++ b/dist/components/VirtualizedFileDiff.js +@@ -552,7 +552,7 @@ var VirtualizedFileDiff = class extends FileDiff { + rerender() { + if (!this.enabled || this.fileDiff == null && this.additionFile == null && this.deletionFile == null) return; + const latestDiff = this.getLatestDiff(); +- const nextRenderDiff = latestDiff == null ? void 0 : this.hunksRenderer.getDiffForNextRender(latestDiff); ++ const nextRenderDiff = latestDiff == null || this.options.collapsed === true ? latestDiff : this.hunksRenderer.getDiffForNextRender(latestDiff); + if (this.isSimpleMode()) this.pendingRender = void 0; + this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, !areDiffTargetsEqual(this.getRenderedDiff(), nextRenderDiff)); +@@ -724,7 +724,7 @@ var VirtualizedFileDiff = class extends FileDiff { + const previousRenderedDiff = this.getRenderedDiff(); + const { pendingRender } = this; + const previousLayoutDiff = pendingRender?.diff ?? previousRenderedDiff; +- const pendingRenderDiff = this.hunksRenderer.getDiffForNextRender(latestDiff); ++ const pendingRenderDiff = this.options.collapsed === true ? latestDiff : this.hunksRenderer.getDiffForNextRender(latestDiff); + this.pendingRender = { + latestDiff, + diff: pendingRenderDiff diff --git a/dist/components/web-components.js b/dist/components/web-components.js index 6e7b486..5b7290f 100644 --- a/dist/components/web-components.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01e7f2be6dc46..2d0b7d0f0c130 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,7 +168,7 @@ patchedDependencies: '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 - '@pierre/diffs@1.5.2': 298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7 + '@pierre/diffs@1.5.2': 2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.21.0': caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e @@ -350,7 +350,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -838,7 +838,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -1126,7 +1126,7 @@ importers: dependencies: '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -16131,7 +16131,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -16146,7 +16146,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)