authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 00:37:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 00:37:01-07:00
log6270a6f88bea4c2fe07a43e69693a918cf94a353
tree31c0baffbf2fc419871bd6dd866830a3943de58f
parent82c2b7ffb4d450572f5baf9a70693f1e25cfdfed
signature Signed by PGP key B5690EEEBB952194

fix(web): retain wrapped row heights during edits (#10018)


3 files changed, 843 insertions(+), 7 deletions(-)

apps/web/src/components/files/fileEditorVirtualization.test.ts created+723
......@@ -0,0 +1,723 @@
1import {
2 getSharedHighlighter,
3 VirtualizedFile,
4 Virtualizer,
5 type FileContents,
6} from "@pierre/diffs";
7import { Editor, TextDocument } from "@pierre/diffs/editor";
8import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10const renderingManagerUrl = new URL(
11 "./managers/UniversalRenderingManager.js",
12 import.meta.resolve("@pierre/diffs"),
13);
14const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as {
15 clearRenderQueue(): void;
16};
17
18// Layout measurements are controlled here. The real reconciler, document and
19// renderer calculate positions. This does not simulate native CSS wrapping.
20class MeasuredElement {
21 static geometryReads = 0;
22 children: MeasuredElement[] = [];
23 dataset: Record<string, string> = {};
24 nextElementSibling: MeasuredElement | null = null;
25 width = 283;
26
27 constructor(readonly height = 0) {}
28
29 getBoundingClientRect() {
30 MeasuredElement.geometryReads += 1;
31 return { top: 0, height: this.height, width: this.width };
32 }
33}
34
35class MeasuredCodeElement extends MeasuredElement {
36 readonly tagName = "CODE";
37
38 get firstElementChild() {
39 return this.children[0] ?? null;
40 }
41}
42
43const observers: RecordedResizeObserver[] = [];
44const animationFrames = new Map<number, FrameRequestCallback>();
45let nextFrameId = 0;
46
47class RecordedResizeObserver {
48 readonly targets = new Set<Element>();
49
50 constructor(readonly callback: ResizeObserverCallback) {
51 observers.push(this);
52 }
53
54 observe(target: Element) {
55 this.targets.add(target);
56 }
57
58 unobserve(target: Element) {
59 this.targets.delete(target);
60 }
61
62 disconnect() {
63 this.targets.clear();
64 }
65
66 deliver(target: HTMLElement) {
67 const rect = target.getBoundingClientRect();
68 const size = { inlineSize: rect.width, blockSize: rect.height };
69 this.callback(
70 [
71 {
72 target,
73 contentRect: rect,
74 contentBoxSize: [size],
75 borderBoxSize: [size],
76 devicePixelContentBoxSize: [size],
77 },
78 ],
79 this as unknown as ResizeObserver,
80 );
81 }
82}
83
84function drainRenderFrames() {
85 const errors = vi.spyOn(console, "error");
86 try {
87 for (let frame = 0; animationFrames.size > 0; frame += 1) {
88 if (frame === 10) throw new Error("The production render queue did not settle");
89 const callbacks = [...animationFrames.values()];
90 animationFrames.clear();
91 for (const callback of callbacks) callback(frame);
92 }
93 expect(errors).not.toHaveBeenCalled();
94 } finally {
95 errors.mockRestore();
96 }
97}
98
99function measuredElement(element: MeasuredElement): HTMLElement {
100 return element as unknown as HTMLElement;
101}
102
103class LayoutVirtualizer extends Virtualizer {
104 override getOffsetInScrollContainer(_element: HTMLElement) {
105 return 0;
106 }
107}
108
109class MeasuredFile extends VirtualizedFile {
110 override top = 0;
111
112 override attachEditor(editor: Parameters<VirtualizedFile["attachEditor"]>[0]) {
113 this.editor = editor;
114 return () => {
115 this.editor = undefined;
116 };
117 }
118
119 async initialize(file: FileContents) {
120 this.prepareCodeViewItem(file, 0);
121 await this.fileRenderer.initializeHighlighter();
122 expect(
123 this.fileRenderer.renderFile(file, {
124 startingLine: 5950,
125 totalLines: 51,
126 bufferBefore: 0,
127 bufferAfter: 0,
128 }),
129 ).toBeDefined();
130 this.fileContainer = measuredElement(new MeasuredElement());
131 }
132
133 measure(
134 rows: ReadonlyArray<readonly [lineIndex: number, height: number]>,
135 contentWidth = 226.25,
136 ) {
137 const content = new MeasuredElement();
138 content.width = contentWidth;
139 content.children = rows.map(([lineIndex, height]) => {
140 const row = new MeasuredElement(height);
141 row.dataset.lineIndex = String(lineIndex);
142 return row;
143 });
144 const code = new MeasuredCodeElement();
145 code.width = contentWidth + 56.75;
146 code.children = [new MeasuredElement(), content];
147 this.code = measuredElement(code);
148 this.reconcileHeights();
149 }
150
151 resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) {
152 const code = this.code;
153 const content = code?.children[1];
154 if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) {
155 throw new Error("Expected measured code and content");
156 }
157 code.width = codeWidth;
158 content.width = contentWidth;
159 }
160
161 observeLayout() {
162 const pre = new MeasuredElement();
163 if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code");
164 pre.children = [this.code];
165 this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, {
166 disableAnnotations: true,
167 columnVariables: "measure",
168 });
169 const code = this.code;
170 const observer = observers.find((candidate) => candidate.targets.has(code));
171 if (observer === undefined) throw new Error("The real resize manager did not observe code");
172 return () => observer.deliver(code);
173 }
174
175 dispose() {
176 this.fileContainer = undefined;
177 this.code = undefined;
178 this.cleanUp();
179 }
180}
181
182const instances: MeasuredFile[] = [];
183const editors: Editor<undefined>[] = [];
184
185beforeAll(async () => {
186 await getSharedHighlighter({
187 themes: ["pierre-dark"],
188 langs: ["text"],
189 preferredHighlighter: "shiki-wasm",
190 });
191});
192
193beforeEach(() => {
194 observers.length = 0;
195 animationFrames.clear();
196 MeasuredElement.geometryReads = 0;
197 vi.stubGlobal("HTMLElement", MeasuredElement);
198 vi.stubGlobal("Document", MeasuredElement);
199 vi.stubGlobal("ResizeObserver", RecordedResizeObserver);
200 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
201 const id = ++nextFrameId;
202 animationFrames.set(id, callback);
203 return id;
204 });
205 vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id));
206});
207
208afterEach(() => {
209 for (const editor of editors.splice(0)) editor.cleanUp();
210 for (const instance of instances.splice(0)) instance.dispose();
211 clearRenderQueue();
212 animationFrames.clear();
213 vi.unstubAllGlobals();
214});
215
216async function makeFixture(
217 overflow: "wrap" | "scroll" = "wrap",
218 lineCount = 6001,
219 contentWidth = 226.25,
220) {
221 const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n");
222 const file: FileContents = {
223 name: "wrapped.txt",
224 contents,
225 cacheKey: `wrapped:${overflow}`,
226 lang: "text",
227 };
228 const document = new TextDocument(file.name, contents, "text");
229 const instance = new MeasuredFile(
230 {
231 overflow,
232 disableFileHeader: true,
233 theme: "pierre-dark",
234 preferredHighlighter: "shiki-wasm",
235 useTokenTransformer: true,
236 controlledSelection: true,
237 },
238 new LayoutVirtualizer(),
239 );
240 instances.push(instance);
241 await instance.initialize(file);
242 instance.measure(
243 [
244 [0, 80],
245 [120, 60],
246 [4999, 100],
247 [5000, 80],
248 [5999, 100],
249 [6000, 60],
250 ],
251 contentWidth,
252 );
253 const apply = (change: { startLine: number } | undefined, passStartLine = true) => {
254 if (change === undefined) throw new Error("Expected a document change");
255 file.contents = document.getText();
256 instance.applyDocumentChange(
257 document,
258 undefined,
259 false,
260 passStartLine ? change.startLine : undefined,
261 );
262 };
263 const append = () => {
264 const position = document.positionAt(document.getText().length);
265 apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]));
266 };
267 return { instance, document, file, apply, append };
268}
269
270describe("wrapped editor document changes", () => {
271 it("preserves the position above an EOF insertion across layout checkpoints", async () => {
272 const { instance, document, append } = await makeFixture();
273 const previousLastLine = document.lineCount;
274 const before = instance.getLinePosition(previousLastLine);
275 expect(before).toEqual({ top: 120328, height: 60 });
276 const viewport = { top: before!.top - 100, bottom: before!.top + 80 };
277 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 });
278
279 append();
280
281 expect(document.lineCount).toBe(previousLastLine + 1);
282 expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 });
283 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 });
284 expect(instance.getVirtualizedHeight()).toBe(120376);
285 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 });
286 });
287
288 it("invalidates changed and shifted rows after an insertion in the middle", async () => {
289 const { instance, document, apply } = await makeFixture();
290 const before = instance.getLinePosition(5001);
291 const position = { line: 5000, character: 2 };
292 apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]));
293
294 expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 });
295 expect(instance.getLineHeight(4999)).toBe(100);
296 expect(instance.getLineHeight(5000)).toBe(20);
297 expect(instance.getLineHeight(5999)).toBe(20);
298 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 });
299 });
300
301 it("keeps preceding measurements when a deletion crosses a checkpoint", async () => {
302 const { instance, document, apply } = await makeFixture();
303 const before = instance.getLinePosition(5000);
304 apply(
305 document.applyEdits([
306 {
307 range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } },
308 newText: "",
309 },
310 ]),
311 );
312
313 expect(document.lineCount).toBe(5999);
314 expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 });
315 expect(instance.getLineHeight(120)).toBe(60);
316 expect(instance.getLineHeight(4999)).toBe(20);
317 expect(instance.getLineHeight(6000)).toBe(20);
318 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 });
319 });
320
321 it("uses the earliest changed line for edits at multiple selections", async () => {
322 const { instance, document, apply } = await makeFixture();
323 const before = instance.getLinePosition(121);
324 apply(
325 document.applyEdits(
326 [120, 5000].map((line) => ({
327 range: { start: { line, character: 2 }, end: { line, character: 2 } },
328 newText: "\n",
329 })),
330 ),
331 );
332
333 expect(document.lineCount).toBe(6003);
334 expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 });
335 expect(instance.getLineHeight(0)).toBe(80);
336 expect(instance.getLineHeight(120)).toBe(20);
337 expect(instance.getLineHeight(4999)).toBe(20);
338 });
339
340 it("retains the unchanged prefix through repeated Enter, undo and redo", async () => {
341 const { instance, document, apply, append } = await makeFixture();
342 const before = instance.getLinePosition(6001);
343 for (let count = 0; count < 60; count += 1) append();
344 expect(document.lineCount).toBe(6061);
345 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
346 apply(document.undo()?.[0]);
347 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
348 apply(document.redo()?.[0]);
349 expect(document.lineCount).toBe(6061);
350 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
351 });
352
353 it("keeps unwrapped positions unchanged", async () => {
354 const { instance, append } = await makeFixture("scroll");
355 const before = instance.getLinePosition(6001);
356 append();
357 expect(instance.getLinePosition(6001)).toEqual(before);
358 expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 });
359 });
360
361 it("fully invalidates measurements when the first changed line is unknown", async () => {
362 const { instance, document, apply } = await makeFixture();
363 const position = document.positionAt(document.getText().length);
364 apply(
365 document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]),
366 false,
367 );
368 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
369 expect(instance.getLineHeight(0)).toBe(20);
370 });
371
372 it("still discards all measured rows after a metric change", async () => {
373 const { instance, file, append } = await makeFixture();
374 append();
375 instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 });
376 instance.prepareCodeViewItem(file, 0);
377 expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 });
378 });
379
380 it("still discards all measured rows when annotations change", async () => {
381 const { instance, file, append } = await makeFixture();
382 append();
383 instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]);
384 instance.prepareCodeViewItem(file, 0);
385 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
386 });
387});
388
389describe("wrapped measurement widths", () => {
390 it.each([
391 [226.25, 482.25],
392 [482.25, 226.25],
393 ])(
394 "drops offscreen measurements when content changes from %spx to %spx",
395 async (before, after) => {
396 const { instance } = await makeFixture("wrap", 6001, before);
397 instance.measure([[6000, 60]], after);
398 expect(instance.getLineHeight(0)).toBe(20);
399 expect(instance.getLineHeight(120)).toBe(20);
400 expect(instance.getLineHeight(6000)).toBe(60);
401 expect(instance.getVirtualizedHeight()).toBe(120076);
402 },
403 );
404
405 it.each([
406 [226.25, 482.25],
407 [482.25, 226.25],
408 ])(
409 "does not retain old-width prefix heights after a %spx to %spx resize and edit",
410 async (before, after) => {
411 const { instance, append } = await makeFixture("wrap", 6001, before);
412 instance.measure([[6000, 60]], after);
413 append();
414 expect(instance.getLineHeight(0)).toBe(20);
415 expect(instance.getLineHeight(120)).toBe(20);
416 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
417 },
418 );
419
420 it("repairs an edit before resize delivery when the real resize and render queues drain", async () => {
421 const { instance, append } = await makeFixture();
422 instance.measure([[6000, 60]]);
423 const deliverResize = instance.observeLayout();
424 instance.resizeContent(482.25);
425 const readsBeforeEdit = MeasuredElement.geometryReads;
426 append();
427 expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit);
428 // No synchronous geometry read: the resize entry owns invalidation.
429 expect(instance.getLineHeight(0)).toBe(80);
430 deliverResize();
431 drainRenderFrames();
432 expect(instance.getLineHeight(0)).toBe(20);
433 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 });
434 });
435
436 it("keeps measured prefixes through same-width reconciliation and editing", async () => {
437 const { instance, append } = await makeFixture();
438 instance.measure([[6000, 60]]);
439 append();
440 expect(instance.getLineHeight(0)).toBe(80);
441 expect(instance.getLineHeight(120)).toBe(60);
442 expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 });
443 });
444
445 it("preserves measurements on first and repeated same-width resize deliveries", async () => {
446 const { instance } = await makeFixture();
447 const before = instance.getVirtualizedHeight();
448 const deliverResize = instance.observeLayout();
449 deliverResize();
450 deliverResize();
451 drainRenderFrames();
452 expect(instance.getLineHeight(0)).toBe(80);
453 expect(instance.getVirtualizedHeight()).toBe(before);
454 });
455
456 it.each([
457 [226.25, 482.25],
458 [482.25, 226.25],
459 ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => {
460 const { instance } = await makeFixture("wrap", 6001, before);
461 instance.measure([[6000, 20]], before);
462 const deliverResize = instance.observeLayout();
463 instance.resizeContent(after);
464 deliverResize();
465 drainRenderFrames();
466 expect(instance.getLineHeight(0)).toBe(20);
467 expect(instance.getVirtualizedHeight()).toBe(120036);
468 });
469
470 it("keeps width validity when a new editor attaches to the same file", async () => {
471 const { instance } = await makeFixture();
472 instance.measure([[6000, 20]]);
473 const deliverResize = instance.observeLayout();
474 vi.stubGlobal("SVGSVGElement", EditorElement);
475 vi.stubGlobal(
476 "document",
477 Object.assign(new EditorElement(), { createElement: () => new EditorElement() }),
478 );
479 const first = new Editor<undefined>();
480 editors.push(first);
481 first.edit(instance);
482 first.cleanUp();
483 const second = new Editor<undefined>();
484 editors.push(second);
485 second.edit(instance);
486 instance.resizeContent(482.25);
487 deliverResize();
488 drainRenderFrames();
489 expect(instance.getLineHeight(0)).toBe(20);
490 expect(instance.getVirtualizedHeight()).toBe(120036);
491 });
492
493 it("ignores stale resize deliveries after cleanup", async () => {
494 const { instance } = await makeFixture();
495 const deliverResize = instance.observeLayout();
496 instance.dispose();
497 clearRenderQueue();
498 animationFrames.clear();
499 deliverResize();
500 expect(animationFrames.size).toBe(0);
501 });
502
503 it("does not discard a stable code width for gutter subpixel rounding", async () => {
504 const { instance } = await makeFixture("wrap", 6001, 482.25);
505 const deliverResize = instance.observeLayout();
506 instance.resizeContent(482.234375, 539);
507 deliverResize();
508 drainRenderFrames();
509 expect(instance.getLineHeight(0)).toBe(80);
510 });
511
512 it("waits for a visible width instead of caching measurements while hidden", async () => {
513 const { instance } = await makeFixture();
514 instance.measure([[6000, 20]]);
515 const deliverResize = instance.observeLayout();
516 instance.resizeContent(0, 0);
517 deliverResize();
518 drainRenderFrames();
519 expect(instance.getLineHeight(0)).toBe(80);
520 instance.resizeContent(482.25);
521 deliverResize();
522 drainRenderFrames();
523 expect(instance.getLineHeight(0)).toBe(20);
524 expect(instance.getVirtualizedHeight()).toBe(120036);
525 });
526});
527
528// Supply inert DOM transport so public Editor edits execute its real tokenizer
529// and layout handoff. No native wrapping, observer delivery or scrolling is modeled.
530class EditorElement extends MeasuredElement {
531 style: Record<string, string> = {};
532 parentElement: EditorElement | null = null;
533
534 appendChild(child: EditorElement) {
535 child.parentElement = this;
536 this.children.push(child);
537 return child;
538 }
539
540 prepend(child: EditorElement) {
541 child.parentElement = this;
542 this.children.unshift(child);
543 }
544
545 replaceChildren(...children: (EditorElement | string)[]) {
546 this.children = [];
547 for (const child of children) if (typeof child !== "string") this.appendChild(child);
548 }
549
550 setAttribute() {}
551 removeAttribute() {}
552 addEventListener() {}
553 removeEventListener() {}
554 after() {}
555
556 remove() {
557 if (this.parentElement) {
558 this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
559 }
560 }
561
562 set innerHTML(value: string) {
563 expect(value.startsWith("<svg")).toBe(true);
564 this.replaceChildren(new EditorElement());
565 }
566
567 get firstElementChild() {
568 return this.children[0] ?? null;
569 }
570
571 querySelectorAll(selector: string) {
572 expect(selector).toBe("[data-code]");
573 return this.children.filter((child) => "code" in child.dataset);
574 }
575
576 querySelector(selector: string) {
577 expect(selector).toBe("[data-deletions]");
578 return null;
579 }
580
581 getContext() {
582 return { measureText: (text: string) => ({ width: text.length * 8 }) };
583 }
584}
585
586async function makeEditorFixture(lineCount: number) {
587 const { instance, file } = await makeFixture("wrap", lineCount);
588 vi.stubGlobal("SVGSVGElement", EditorElement);
589 vi.stubGlobal("Document", EditorElement);
590 vi.stubGlobal(
591 "document",
592 Object.assign(new EditorElement(), { createElement: () => new EditorElement() }),
593 );
594 vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
595 vi.stubGlobal("requestAnimationFrame", () => 1);
596 vi.stubGlobal("cancelAnimationFrame", () => {});
597 vi.stubGlobal("getComputedStyle", () => ({
598 paddingTop: "0px",
599 fontSize: "13px",
600 fontFamily: "monospace",
601 tabSize: "2",
602 lineHeight: "20px",
603 }));
604 vi.stubGlobal(
605 "ResizeObserver",
606 class {
607 observe() {}
608 disconnect() {}
609 },
610 );
611 instance.setOptions({
612 ...instance.options,
613 useTokenTransformer: true,
614 controlledSelection: true,
615 themeType: "dark",
616 });
617 const content = new EditorElement();
618 content.dataset.content = "";
619 const gutter = new EditorElement();
620 gutter.dataset.gutter = "";
621 const code = new EditorElement();
622 code.dataset.code = "";
623 code.appendChild(gutter);
624 code.appendChild(content);
625 const shadow = new EditorElement();
626 shadow.appendChild(code);
627 const host = Object.assign(new EditorElement(), { shadowRoot: shadow });
628 const highlighter = await getSharedHighlighter({
629 themes: ["pierre-dark"],
630 langs: ["text"],
631 preferredHighlighter: "shiki-wasm",
632 });
633 const editor = new Editor<undefined>();
634 editors.push(editor);
635 editor.edit(instance);
636 editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, {
637 startingLine: 0,
638 totalLines: 1,
639 bufferBefore: 0,
640 bufferAfter: 0,
641 });
642 const append = (count: number) => {
643 const lines = editor.getText().split("\n");
644 const end = { line: lines.length - 1, character: lines.at(-1)!.length };
645 editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]);
646 };
647 const remove = (count: number) => {
648 const lines = editor.getText().split("\n");
649 const startLine = lines.length - count - 1;
650 editor.applyEdits([
651 {
652 range: {
653 start: { line: startLine, character: lines[startLine]!.length },
654 end: { line: lines.length - 1, character: lines.at(-1)!.length },
655 },
656 newText: "",
657 },
658 ]);
659 };
660 return { instance, editor, append, remove };
661}
662
663describe("editor gutter-width changes", () => {
664 it.each([
665 [9999, 1],
666 [9998, 3],
667 ])(
668 "clears prefix measurements when %i lines grow by %i across a digit boundary",
669 async (lines, count) => {
670 const { instance, editor, append } = await makeEditorFixture(lines);
671 expect(instance.getLineHeight(0)).toBe(80);
672 append(count);
673 expect(editor.getText().split("\n")).toHaveLength(lines + count);
674 expect(instance.getLineHeight(0)).toBe(20);
675 expect(instance.getLineHeight(5000)).toBe(20);
676 },
677 );
678
679 it.each([
680 [10000, 1],
681 [10002, 4],
682 ])(
683 "clears prefix measurements when %i lines shrink by %i across a digit boundary",
684 async (lines, count) => {
685 const { instance, editor, remove } = await makeEditorFixture(lines);
686 remove(count);
687 expect(editor.getText().split("\n")).toHaveLength(lines - count);
688 expect(instance.getLineHeight(0)).toBe(20);
689 expect(instance.getLineHeight(5000)).toBe(20);
690 },
691 );
692
693 it("clears newly measured prefixes on undo and redo across a digit boundary", async () => {
694 const { instance, editor, append } = await makeEditorFixture(9999);
695 append(1);
696 instance.measure([[0, 100]]);
697 editor.undo();
698 expect(editor.getText().split("\n")).toHaveLength(9999);
699 expect(instance.getLineHeight(0)).toBe(20);
700 instance.measure([[0, 80]]);
701 editor.redo();
702 expect(editor.getText().split("\n")).toHaveLength(10000);
703 expect(instance.getLineHeight(0)).toBe(20);
704 });
705
706 it.each([
707 [9998, 1],
708 [10000, 2],
709 ])(
710 "retains prefix measurements when %i lines grow by %i without changing digit width",
711 async (lines, count) => {
712 const { instance, editor, append } = await makeEditorFixture(lines);
713 append(count);
714 expect(editor.getText().split("\n")).toHaveLength(lines + count);
715 expect(instance.getLineHeight(0)).toBe(80);
716 expect(instance.getLineHeight(5000)).toBe(80);
717 editor.undo();
718 expect(instance.getLineHeight(0)).toBe(80);
719 editor.redo();
720 expect(instance.getLineHeight(0)).toBe(80);
721 },
722 );
723});
patches/@pierre%2Fdiffs@1.3.0-beta.10.patch+115-2
......@@ -1,3 +1,79 @@
1diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts
2--- a/dist/components/VirtualizedFile.d.ts
3+++ b/dist/components/VirtualizedFile.d.ts
4@@ -42,7 +42,7 @@ declare class VirtualizedFile<LAnnotation = undefined> extends File<LAnnotation> {
5 private computeApproximateSize;
6 setVisibility(visible: boolean): void;
7 rerender(): void;
8- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean): void;
9+ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean, startLine?: number): void;
10 protected renderPreparedFile({
11 fileContainer,
12 file,
13diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js
14--- a/dist/components/VirtualizedFile.js
15+++ b/dist/components/VirtualizedFile.js
16@@ -20,6 +20,7 @@
17 cache = {
18 heights: /* @__PURE__ */ new Map(),
19 checkpoints: [],
20+ codeWidth: void 0,
21 fileAnnotationHeight: 0
22 };
23 isVisible = false;
24@@ -31,6 +32,8 @@
25 super(options, workerManager, isContainerManaged);
26 this.virtualizer = virtualizer;
27 this.metrics = metrics;
28+ const simpleVirtualizer = this.getSimpleVirtualizer();
29+ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this);
30 }
31 setMetrics(metrics, force = false) {
32 if (!force && areObjectsEqual(this.metrics, metrics)) return;
33@@ -70,10 +73,12 @@
34 if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.");
35 super.setThemeType(themeType);
36 }
37- resetLayoutCache(recompute = false, resetRenderRange = true) {
38+ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) {
39 this.layoutDirty = true;
40- this.cache.fileAnnotationHeight = 0;
41- if (this.cache.heights.size > 0) this.cache.heights.clear();
42+ if (startLine === 0) this.cache.fileAnnotationHeight = 0;
43+ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured.
44+ if (startLine === 0) this.cache.heights.clear();
45+ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex);
46 if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0;
47 if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;
48 if (recompute && this.isSimpleMode()) this.computeApproximateSize();
49@@ -91,6 +96,13 @@
50 if (this.code == null) return hasHeightChange;
51 const content = this.code.children[1];
52 if (!(content instanceof HTMLElement)) return hasHeightChange;
53+ const codeWidth = this.code.getBoundingClientRect().width;
54+ if (!(codeWidth > 0)) return hasHeightChange;
55+ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) {
56+ this.resetLayoutCache(false, false);
57+ hasHeightChange = true;
58+ }
59+ this.cache.codeWidth = codeWidth;
60 const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations);
61 if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) {
62 const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;
63@@ -287,11 +299,11 @@
64 this.forceRenderOverride = true;
65 this.virtualizer.instanceChanged(this, false);
66 }
67- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) {
68+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) {
69 const previousRenderRange = this.renderRange;
70 super.applyDocumentChange(textDocument, newLineAnnotations);
71 this.getSimpleVirtualizer()?.markDOMDirty();
72- this.resetLayoutCache(this.isSimpleMode(), false);
73+ this.resetLayoutCache(this.isSimpleMode(), false, startLine);
74 if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) {
75 const windowSpecs = this.virtualizer.getWindowSpecs();
76 const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs);
177diff --git a/dist/editor/editor.js b/dist/editor/editor.js
278index ff78e2a..f9df318 100644
379--- a/dist/editor/editor.js
......@@ -28,12 +104,18 @@ index ff78e2a..f9df318 100644
28104 const gutterRow = resolveGutterTarget(e.composedPath()[0]);
29105 if (gutterRow?.dataset.lineType === "change-deletion") {
30106 const code = gutterRow.closest("[data-code]");
31@@ -1522,6 +1520,7 @@ var Editor = class {
107@@ -1522,6 +1520,12 @@ var Editor = class {
32108 if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow;
33109 }
34110 fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange);
35111+ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText();
36 if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer);
112- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer);
113+ if (didLineCountChange) {
114+ const previousLineCount = change.lineCount - change.lineDelta;
115+ // A wider or narrower line-number gutter can rewrap unchanged rows.
116+ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0;
117+ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine);
118+ }
37119 if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache();
38120 if (newLineAnnotations !== void 0) {
39121@@ -1788,6 +1787,7 @@ var Editor = class {
......@@ -44,6 +126,37 @@ index ff78e2a..f9df318 100644
44126 try {
45127 this.#fileInstance?.setSelectedLines(range, {
46128 notify: false,
129diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
130--- a/dist/managers/ResizeManager.d.ts
131+++ b/dist/managers/ResizeManager.d.ts
132@@ -5,6 +5,8 @@
133 columnVariables?: ResizeManagerColumnVariableMode;
134 }
135 declare class ResizeManager {
136+ /** Schedule owner measurement after an observed code or gutter size change. */
137+ onResize?: () => void;
138 private static resizeObserver;
139 private static managersByElement;
140 private static getResizeObserver;
141diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
142--- a/dist/managers/ResizeManager.js
143+++ b/dist/managers/ResizeManager.js
144@@ -19,6 +19,7 @@
145 for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);
146 }
147 observedNodes = /* @__PURE__ */ new Map();
148+ onResize;
149 setup(pre, { disableAnnotations, columnVariables = "apply" }) {
150 const annotationUpdates = /* @__PURE__ */ new Set();
151 const applyColumnVariables = columnVariables === "apply";
152@@ -212,6 +213,7 @@
153 this.applyAnnotationUpdates(annotationUpdates);
154 annotationUpdates.clear();
155 this.applyColumnUpdates(codeUpdates);
156+ if (codeUpdates.size > 0) this.onResize?.();
157 codeUpdates.clear();
158 }
159 applyAnnotationUpdates(annotationUpdates) {
47160diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js
48161index e9f62f5..af82a46 100644
49162--- a/dist/react/utils/useFileInstance.js
pnpm-lock.yaml+5-5
......@@ -92,7 +92,7 @@ patchedDependencies:
9292 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
9393 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
9494 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43
95 '@pierre/diffs@1.3.0-beta.10': c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4
95 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
9696 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
9797 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
9898 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
......@@ -242,7 +242,7 @@ importers:
242242 version: 1.9.1
243243 '@pierre/diffs':
244244 specifier: 'catalog:'
245 version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
245 version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
246246 '@react-native-ai/apple':
247247 specifier: 0.12.0
248248 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
......@@ -583,7 +583,7 @@ importers:
583583 version: 1.8.0
584584 '@pierre/diffs':
585585 specifier: 'catalog:'
586 version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
586 version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
587587 '@pierre/trees':
588588 specifier: 1.0.0-beta.4
589589 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -14028,7 +14028,7 @@ snapshots:
1402814028 tslib: 2.8.1
1402914029 webcrypto-core: 1.9.2
1403014030
14031 '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
14031 '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
1403214032 dependencies:
1403314033 '@pierre/theme': 1.1.0
1403414034 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)
......@@ -14042,7 +14042,7 @@ snapshots:
1404214042 transitivePeerDependencies:
1404314043 - '@shikijs/themes'
1404414044
14045 '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
14045 '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1404614046 dependencies:
1404714047 '@pierre/theme': 1.1.0
1404814048 '@pierre/theming': 0.0.2(@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)