diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts index 111ff291a7a8..cded86aedb45 100644 --- a/apps/web/src/components/files/fileEditorVirtualization.test.ts +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -148,6 +148,7 @@ class MeasuredFile extends VirtualizedFile { measure( rows: ReadonlyArray, contentWidth = 226.25, + codeWidth = contentWidth + 56.75, ) { const content = new MeasuredElement(); content.width = contentWidth; @@ -157,7 +158,7 @@ class MeasuredFile extends VirtualizedFile { return row; }); const code = new MeasuredCodeElement(); - code.width = contentWidth + 56.75; + code.width = codeWidth; code.children = [new MeasuredElement(), content]; this.code = measuredElement(code); this.reconcileHeights(); @@ -406,33 +407,35 @@ describe("wrapped measurement widths", () => { [226.25, 482.25], [482.25, 226.25], ])( - "drops offscreen measurements when content changes from %spx to %spx", + "keeps offscreen measurements as estimates when content changes from %spx to %spx", async (before, after) => { const { instance } = await makeFixture("wrap", 6001, before); + const height = instance.getVirtualizedHeight(); instance.measure([[6000, 60]], after); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); expect(instance.getLineHeight(6000)).toBe(60); - expect(instance.getVirtualizedHeight()).toBe(120076); + expect(instance.getVirtualizedHeight()).toBe(height); }, ); it.each([ + [226.25, 226.25], [226.25, 482.25], [482.25, 226.25], ])( - "does not retain old-width prefix heights after a %spx to %spx resize and edit", + "keeps prefix estimates through a %spx to %spx reconciliation and edit", async (before, after) => { const { instance, append } = await makeFixture("wrap", 6001, before); instance.measure([[6000, 60]], after); append(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(120)).toBe(20); - expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); }, ); - it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { + it("remeasures rendered rows on resize delivery without reading geometry during the edit", async () => { const { instance, append } = await makeFixture(); instance.measure([[6000, 60]]); const deliverResize = instance.observeLayout(); @@ -440,21 +443,11 @@ describe("wrapped measurement widths", () => { const readsBeforeEdit = MeasuredElement.geometryReads; append(); expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); - // No synchronous geometry read: the resize entry owns invalidation. - expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(6000)).toBe(20); deliverResize(); drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); - }); - - it("keeps measured prefixes through same-width reconciliation and editing", async () => { - const { instance, append } = await makeFixture(); - instance.measure([[6000, 60]]); - append(); expect(instance.getLineHeight(0)).toBe(80); - expect(instance.getLineHeight(120)).toBe(60); - expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 60 }); }); it("preserves measurements on first and repeated same-width resize deliveries", async () => { @@ -471,38 +464,16 @@ describe("wrapped measurement widths", () => { it.each([ [226.25, 482.25], [482.25, 226.25], - ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { + ])("keeps offscreen estimates across a %spx to %spx resize delivery", async (before, after) => { const { instance } = await makeFixture("wrap", 6001, before); instance.measure([[6000, 20]], before); + const height = instance.getVirtualizedHeight(); const deliverResize = instance.observeLayout(); instance.resizeContent(after); deliverResize(); drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); - }); - - it("keeps width validity when a new editor attaches to the same file", async () => { - const { instance } = await makeFixture(); - instance.measure([[6000, 20]]); - const deliverResize = instance.observeLayout(); - vi.stubGlobal("SVGSVGElement", EditorElement); - vi.stubGlobal( - "document", - Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), - ); - const first = new Editor("file"); - editors.push(first); - first.edit(instance); - first.cleanUp(); - const second = new Editor("file"); - editors.push(second); - second.edit(instance); - instance.resizeContent(482.25); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getVirtualizedHeight()).toBe(height); }); it("ignores stale resize deliveries after cleanup", async () => { @@ -515,35 +486,32 @@ describe("wrapped measurement widths", () => { expect(animationFrames.size).toBe(0); }); - it("does not discard a stable code width for gutter subpixel rounding", async () => { - const { instance } = await makeFixture("wrap", 6001, 482.25); - const deliverResize = instance.observeLayout(); - instance.resizeContent(482.234375, 539); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(80); - }); - - it("waits for a visible width instead of caching measurements while hidden", async () => { + it("does not measure rows while the code element is hidden", async () => { const { instance } = await makeFixture(); - instance.measure([[6000, 20]]); - const deliverResize = instance.observeLayout(); - instance.resizeContent(0, 0); - deliverResize(); - drainRenderFrames(); + const height = instance.getVirtualizedHeight(); + instance.measure([[0, 0]], 0, 0); expect(instance.getLineHeight(0)).toBe(80); - instance.resizeContent(482.25); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); + expect(instance.getVirtualizedHeight()).toBe(height); }); }); // Supply inert DOM transport so public Editor edits execute its real tokenizer // and layout handoff. No native wrapping, observer delivery or scrolling is modeled. +class StyleDeclaration { + [property: string]: unknown; + + getPropertyValue(property: string) { + const value = this[property]; + return typeof value === "string" ? value : ""; + } + + setProperty(property: string, value: string) { + this[property] = value; + } +} + class EditorElement extends MeasuredElement { - style: Record = {}; + style = new StyleDeclaration(); parentElement: EditorElement | null = null; override appendChild(child: EditorElement) { @@ -570,14 +538,33 @@ class EditorElement extends MeasuredElement { removeAttribute() {} addEventListener() {} removeEventListener() {} - after() {} + + after(sibling: EditorElement) { + const parent = this.parentElement; + if (!parent) return; + sibling.remove(); + sibling.parentElement = parent; + parent.children.splice(parent.children.indexOf(this) + 1, 0, sibling); + } remove() { if (this.parentElement) { this.parentElement.children = this.parentElement.children.filter((child) => child !== this); } + this.parentElement = null; } + // Rows stack 20px apart inside their parent; a detached row reports 0 like the DOM. + get offsetTop() { + const siblings = this.parentElement?.children ?? []; + const index = siblings.indexOf(this); + return index < 0 ? 0 : index * 20; + } + + scrollIntoView() {} + focus() {} + blur() {} + set innerHTML(value: string) { expect(value.startsWith(" "code" in child.dataset); + const match = /^\[data-([a-z-]+)(?:="([^"]*)")?\]$/.exec(selector); + if (match === null) throw new Error(`Unsupported selector ${selector}`); + const key = match[1]!.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + const value = match[2]; + return findElements( + this, + (element) => + element !== this && + key in element.dataset && + (value === undefined || element.dataset[key] === value), + ); } querySelector(selector: string) { - expect(selector).toBe("[data-deletions]"); - return null; + return this.querySelectorAll(selector)[0] ?? null; } getContext() { @@ -602,15 +598,29 @@ class EditorElement extends MeasuredElement { } } -async function makeEditorFixture(lineCount: number) { +function findElements(root: EditorElement, match: (element: EditorElement) => boolean) { + const found: EditorElement[] = []; + const visit = (element: EditorElement) => { + if (match(element)) found.push(element); + for (const child of element.children) visit(child as EditorElement); + }; + visit(root); + return found; +} + +async function makeEditorFixture(lineCount: number, renderedLines = 1) { const { instance, file } = await makeFixture("wrap", lineCount); vi.stubGlobal("SVGSVGElement", EditorElement); vi.stubGlobal("Document", EditorElement); vi.stubGlobal( "document", - Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + Object.assign(new EditorElement(), { + createElement: () => new EditorElement(), + createDocumentFragment: () => new EditorElement(), + getSelection: () => null, + }), ); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }), getSelection: () => null }); vi.stubGlobal("requestAnimationFrame", () => 1); vi.stubGlobal("cancelAnimationFrame", () => {}); vi.stubGlobal("getComputedStyle", () => ({ @@ -657,7 +667,7 @@ async function makeEditorFixture(lineCount: number) { fileContainer: measuredElement(host), file, lineAnnotations: undefined, - renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 }, + renderRange: { startingLine: 0, totalLines: renderedLines, bufferBefore: 0, bufferAfter: 0 }, }); const append = (count: number) => { const lines = editor.getText().split("\n"); @@ -677,22 +687,22 @@ async function makeEditorFixture(lineCount: number) { }, ]); }; - return { instance, editor, append, remove }; + return { instance, editor, root: shadow, append, remove }; } -describe("editor gutter-width changes", () => { +describe("editor line-number digit boundaries", () => { it.each([ [9999, 1], [9998, 3], ])( - "clears prefix measurements when %i lines grow by %i across a digit boundary", + "keeps prefix measurements when %i lines grow by %i across a digit boundary", async (lines, count) => { const { instance, editor, append } = await makeEditorFixture(lines); expect(instance.getLineHeight(0)).toBe(80); append(count); expect(editor.getText().split("\n")).toHaveLength(lines + count); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); }, ); @@ -700,27 +710,27 @@ describe("editor gutter-width changes", () => { [10000, 1], [10002, 4], ])( - "clears prefix measurements when %i lines shrink by %i across a digit boundary", + "keeps prefix measurements when %i lines shrink by %i across a digit boundary", async (lines, count) => { const { instance, editor, remove } = await makeEditorFixture(lines); remove(count); expect(editor.getText().split("\n")).toHaveLength(lines - count); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); }, ); - it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { + it("keeps the latest measurements on undo and redo across a digit boundary", async () => { const { instance, editor, append } = await makeEditorFixture(9999); append(1); instance.measure([[0, 100]]); editor.undo(); expect(editor.getText().split("\n")).toHaveLength(9999); - expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(0)).toBe(100); instance.measure([[0, 80]]); editor.redo(); expect(editor.getText().split("\n")).toHaveLength(10000); - expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(0)).toBe(80); }); it.each([ @@ -741,3 +751,21 @@ describe("editor gutter-width changes", () => { }, ); }); + +describe("editor undo and redo rows", () => { + it("places the caret on the row a redo re-creates after undo removed it", async () => { + const { editor, root, append } = await makeEditorFixture(100, 100); + editor.setSelections([ + { start: { line: 99, character: 7 }, end: { line: 99, character: 7 }, direction: "none" }, + ]); + append(2); + editor.undo(); + editor.redo(); + expect(editor.getText().split("\n")).toHaveLength(102); + const row = findElements(root, (element) => element.dataset.line === "102").at(-1); + expect(row?.parentElement).not.toBeNull(); + const caret = findElements(root, (element) => "caret" in element.dataset).at(-1); + expect(caret?.style.transform).toContain(`translateY(${row!.offsetTop}px)`); + expect(row!.offsetTop).toBeGreaterThan(0); + }); +}); diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch index fbb672dc90ab..23f48e69c6d9 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -711,15 +711,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil index d1f57cd..5908ad7 100644 --- a/dist/components/VirtualizedFile.js +++ b/dist/components/VirtualizedFile.js -@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { - heights: /* @__PURE__ */ new Map(), - checkpoints: [], - fileAnnotationHeight: 0, -+ codeWidth: void 0, - ghostTextRows: NO_GHOST_TEXT_ROWS - }; - pendingRender; -@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { +@@ -35,6 +35,8 @@ var VirtualizedFile = class extends File { super(options, workerManager, isContainerManaged); this.virtualizer = virtualizer; this.metrics = metrics; @@ -728,7 +720,7 @@ index d1f57cd..5908ad7 100644 } setMetrics(metrics, force = false) { const nextMetrics = computeVirtualFileMetrics(metrics); -@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { +@@ -86,10 +88,14 @@ var VirtualizedFile = class extends File { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } @@ -738,27 +730,23 @@ index d1f57cd..5908ad7 100644 - this.cache.fileAnnotationHeight = 0; - if (this.cache.heights.size > 0) this.cache.heights.clear(); + if (startLine === 0) this.cache.fileAnnotationHeight = 0; -+ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ // Rows before the change keep their measurements as estimates: dropping them ++ // shrinks the file under the viewport and the browser clamps the scroll position ++ // before the rows can be remeasured. Rendered rows are remeasured on reconcile. + if (startLine === 0) this.cache.heights.clear(); + else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; -@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { +@@ -135,6 +141,7 @@ var VirtualizedFile = class extends File { if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; -+ const codeWidth = this.code.getBoundingClientRect().width; -+ if (!(codeWidth > 0)) return hasHeightChange; -+ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { -+ this.resetLayoutCache(false, false); -+ hasHeightChange = true; -+ } -+ this.cache.codeWidth = codeWidth; ++ if (!(this.code.getBoundingClientRect().width > 0)) return hasHeightChange; if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { -@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { +@@ -358,12 +365,12 @@ var VirtualizedFile = class extends File { codeView.instanceChanged(this, true); } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); } @@ -811,16 +799,12 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js index 8f04a6f..c203023 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -1899,7 +1899,11 @@ var Editor = class { +@@ -1899,7 +1899,7 @@ var Editor = class { changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, documentLineCount: textDocument.lineCount }); - if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); -+ if (didLineCountChange) { -+ // A wider or narrower line-number gutter can rewrap unchanged rows. -+ const layoutStartLine = String(change.previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; -+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); -+ } ++ if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, change.startLine); if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711ef69a8bbd..301dc067f276 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 + '@pierre/diffs@1.5.2': a3618d5e6a440e978329748bdb8f5db9204ef5137bc72559fb15d55581544dea '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87 @@ -310,7 +310,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=a3618d5e6a440e978329748bdb8f5db9204ef5137bc72559fb15d55581544dea)(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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)) @@ -741,7 +741,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=a3618d5e6a440e978329748bdb8f5db9204ef5137bc72559fb15d55581544dea)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@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.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -15341,7 +15341,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@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=a3618d5e6a440e978329748bdb8f5db9204ef5137bc72559fb15d55581544dea)(@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) @@ -15356,7 +15356,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=a3618d5e6a440e978329748bdb8f5db9204ef5137bc72559fb15d55581544dea)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)