authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 18:06:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 18:06:57-07:00
logf8ed2a039b7deaa324beda29b44cc74c846311f7
tree0cf2e65a08e52357bed3325526f9435b9b24d4de
parentd021f57bf3a500e419e800c3eb957568bed8f713
signature Signed by PGP key B5690EEEBB952194

chore(deps): upgrade @pierre/diffs to 1.5.2 and @pierre/trees to beta.6 (#16644)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

22 files changed, 505 insertions(+), 951 deletions(-)

apps/mobile/package.json+1-1
......@@ -131,7 +131,7 @@
131131 },
132132 "devDependencies": {
133133 "@effect/vitest": "catalog:",
134 "@pierre/trees": "1.0.0-beta.4",
134 "@pierre/trees": "1.0.0-beta.6",
135135 "@types/react": "~19.3.0",
136136 "@types/react-dom": "~19.2.3",
137137 "babel-preset-expo": "~58.0.8",
apps/mobile/src/features/review/reviewModel.ts+2-3
......@@ -2,7 +2,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
22import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
33import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
44import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
5import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
65import * as Arr from "effect/Array";
76import { pipe } from "effect/Function";
87import * as Order from "effect/Order";
......@@ -374,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender
374373}
375374
376375function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
377 const path = unquoteGitPatchPath(file.name || file.prevName || "");
378 const previousPath = file.prevName ? unquoteGitPatchPath(file.prevName) : null;
376 const path = file.name || file.prevName || "";
377 const previousPath = file.prevName || null;
379378 const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0);
380379 const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0);
381380 const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`;
apps/web/package.json+1-1
......@@ -25,7 +25,7 @@
2525 "@legendapp/list": "catalog:",
2626 "@noble/hashes": "catalog:",
2727 "@pierre/diffs": "catalog:",
28 "@pierre/trees": "1.0.0-beta.4",
28 "@pierre/trees": "1.0.0-beta.6",
2929 "@t3tools/client-runtime": "workspace:*",
3030 "@t3tools/contracts": "workspace:*",
3131 "@t3tools/shared": "workspace:*",
apps/web/src/components/diffs/AnnotatableCodeView.tsx+1-1
......@@ -35,7 +35,7 @@ interface DiffCommentAnnotationGroup {
3535}
3636
3737type DiffCommentLineAnnotation = DiffLineAnnotation<DiffCommentAnnotationGroup>;
38export type AnnotatableCodeViewHandle = CodeViewHandle<DiffCommentAnnotationGroup>;
38export type AnnotatableCodeViewHandle = CodeViewHandle<DiffCommentAnnotationGroup, undefined>;
3939const EMPTY_REVIEW_COMMENTS: ReadonlyArray<ReviewCommentContext> = [];
4040
4141function annotationSide(range: SelectedLineRange): AnnotationSide {
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+5-2
......@@ -98,7 +98,7 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => {
9898
9999vi.mock("@pierre/diffs/react", async (importOriginal) => ({
100100 ...(await importOriginal<typeof import("@pierre/diffs/react")>()),
101 CodeView: (props: CodeViewProps) => {
101 CodeView: (props: CodeViewProps<undefined, undefined>) => {
102102 return props.items?.map((item) =>
103103 item.type === "file" ? <FileOutput key={item.id} file={item.file} /> : null,
104104 );
......@@ -115,6 +115,7 @@ function FileOutput({ file }: { file: FileContents }) {
115115 testState.renderPools.push(pool);
116116 const renderer = new FileRenderer(
117117 { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" },
118 undefined,
118119 render,
119120 pool,
120121 );
......@@ -301,8 +302,10 @@ describe("code-view worker lifecycle", () => {
301302 expect(pool.getStats().totalWorkers).toBe(2);
302303 expect(testState.terminations).toHaveLength(0);
303304 await act(async () => renderer!.update(renderViews(0)));
305 // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down.
306 const timedOut = expect(pending).rejects.toThrow("worker initialization timed out");
304307 await vi.advanceTimersByTimeAsync(30_000);
305 await pending;
308 await timedOut;
306309 await Promise.all(testState.terminations);
307310 await act(async () => {
308311 for (const deliver of testState.heldResponses) deliver();
apps/web/src/components/diffs/StyledDiffCodeView.tsx+4-4
......@@ -261,16 +261,16 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
261261`;
262262
263263export type StyledDiffCodeViewOptions<LAnnotation> = Omit<
264 NonNullable<CodeViewProps<LAnnotation>["options"]>,
264 NonNullable<CodeViewProps<LAnnotation, undefined>["options"]>,
265265 "unsafeCSS" | "itemMetrics" | "layout"
266266>;
267267
268268type StyledDiffCodeViewProps<LAnnotation> = (
269 | Omit<ControlledCodeViewProps<LAnnotation>, "options">
270 | Omit<UncontrolledCodeViewProps<LAnnotation>, "options">
269 | Omit<ControlledCodeViewProps<LAnnotation, undefined>, "options">
270 | Omit<UncontrolledCodeViewProps<LAnnotation, undefined>, "options">
271271) & {
272272 readonly options?: StyledDiffCodeViewOptions<LAnnotation>;
273 readonly viewerRef?: Ref<CodeViewHandle<LAnnotation>>;
273 readonly viewerRef?: Ref<CodeViewHandle<LAnnotation, undefined>>;
274274 /**
275275 * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has
276276 * to restyle chrome the viewer owns — such as replacing its per-file line counts.
apps/web/src/components/files/FilePreviewPanel.tsx+188-98
......@@ -11,9 +11,23 @@ import {
1111 isWorkspaceImagePreviewPath,
1212 isWorkspaceVideoPreviewPath,
1313} from "@t3tools/shared/filePreview";
14import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
15import { Editor } from "@pierre/diffs/editor";
16import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
14import {
15 DEFAULT_TOKENIZE_MAX_LENGTH,
16 VirtualizedFile,
17 getFiletypeFromFileName,
18 type File as FileInstance,
19 type FileContents,
20 type PostRenderPhase,
21 type SelectedLineRange,
22} from "@pierre/diffs";
23import {
24 Editor,
25 type EditorChangeEvent,
26 type EditorFactory,
27 type EditorOptions,
28} from "@pierre/diffs/edit";
29import type { WorkerPoolManager } from "@pierre/diffs/worker";
30import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react";
1731import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
1832import {
1933 isAtomCommandInterrupted,
......@@ -80,7 +94,7 @@ import {
8094import SourceFilePreview from "./ReadOnlySourcePreview";
8195import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
8296import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
83import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
97import { projectFileCacheKey } from "./fileContentRevision";
8498import {
8599 filePreviewReadErrorMessage,
86100 isMarkdownPreviewFile,
......@@ -91,6 +105,7 @@ import {
91105import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
92106import {
93107 getOptimisticProjectFileQueryData,
108 getProjectFileContents,
94109 setProjectFileQueryData,
95110 useProjectFileQuery,
96111} from "./projectFilesQueryState";
......@@ -117,7 +132,12 @@ const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
117132const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
118133const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
119134const RENDER_TABLE_STORAGE_KEY = "t3code.renderTable";
120type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>;
135// Shared by the read-only and annotated surfaces, so it is generic over annotation metadata.
136type FilePostRender = <LAnnotation>(
137 fileContainer: HTMLElement,
138 instance: FileInstance<LAnnotation, undefined>,
139 phase: PostRenderPhase,
140) => void;
121141
122142function WorkspaceImagePreview(props: {
123143 readonly environmentId: EnvironmentId;
......@@ -547,6 +567,67 @@ function useFileLineReveal(
547567 );
548568}
549569
570const createFileEditor: EditorFactory<FileCommentAnnotationGroup, undefined> = (
571 editorType,
572 options,
573 editStateKey,
574) => new Editor(editorType, options, editStateKey);
575
576function editableFileContents(
577 environmentId: EnvironmentId,
578 cwd: string,
579 relativePath: string,
580 contents: string,
581): FileContents {
582 return {
583 name: relativePath,
584 contents,
585 cacheKey: `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`,
586 };
587}
588
589function needsWorkerHighlight(workerPool: WorkerPoolManager | undefined, file: FileContents) {
590 if (workerPool?.isWorkingPool() !== true) return false;
591 if ((file.lang ?? getFiletypeFromFileName(file.name)) === "text") return false;
592 let lines = 1;
593 for (
594 let index = file.contents.indexOf("\n");
595 index !== -1;
596 index = file.contents.indexOf("\n", index + 1)
597 ) {
598 lines += 1;
599 }
600 return lines <= DEFAULT_TOKENIZE_MAX_LENGTH;
601}
602
603/**
604 * Pierre highlights an active edit session on the main thread, so each version
605 * of the file becomes editable only once it has rendered the worker's
606 * highlight. A failed worker highlight falls back to main-thread highlighting.
607 */
608function useEditableAfterHighlight(file: FileContents) {
609 const workerPool = useWorkerPool();
610 const [highlightedFile, setHighlightedFile] = useState<FileContents | null>(null);
611 const needsHighlight = useMemo(() => needsWorkerHighlight(workerPool, file), [file, workerPool]);
612 const ready = !needsHighlight || highlightedFile === file;
613
614 useEffect(() => {
615 if (ready || workerPool === undefined) return;
616 workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file));
617 }, [file, ready, workerPool]);
618
619 const onPostRender = useCallback(
620 (renderedFile: FileContents | undefined, phase: PostRenderPhase) => {
621 if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return;
622 // The pool caches a result just before the instance renders it, so a
623 // render that sees the cache has painted highlighted rows.
624 if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file);
625 },
626 [file, ready, workerPool],
627 );
628 return { ready, onPostRender };
629}
630
550631interface EditableFileSurfaceProps {
551632 environmentId: EnvironmentId;
552633 cwd: string;
......@@ -597,46 +678,65 @@ function EditableFileSurface({
597678 relativePath,
598679 onPendingChange,
599680 });
600 const editor = useMemo(
601 () =>
602 new Editor<FileCommentAnnotationGroup>({
603 persistState: true,
604 persistStateStorage: "inMemory",
605 onChange: (file, nextLineAnnotations) => {
606 setProjectFileQueryData(environmentId, cwd, relativePath, file.contents);
607 saveCoordinator.change(file.contents);
608 if (nextLineAnnotations) {
609 const remapped = remapFileCommentAnnotations(
610 nextLineAnnotations as FileCommentLineAnnotation[],
611 );
612 setLineAnnotations(remapped);
613 for (const annotation of remapped) {
614 for (const entry of annotation.metadata.entries) {
615 if (entry.kind !== "comment") continue;
616 addReviewComment(
617 composerDraftTarget,
618 buildFileReviewComment({
619 id: entry.id,
620 filePath: relativePath,
621 startLine: entry.startLine,
622 endLine: entry.endLine,
623 text: entry.text,
624 contents: file.contents,
625 }),
626 );
627 }
628 }
629 }
630 },
631 }),
632 [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator],
681 // The editor owns the draft, so its own edits echoing back through the file
682 // query keep the file identity. Only a change from elsewhere replaces it.
683 const [externalFile, setExternalFile] = useState(() =>
684 editableFileContents(environmentId, cwd, relativePath, contents),
685 );
686 const [editedContents, setEditedContents] = useState<string | null>(null);
687 if (contents !== (editedContents ?? externalFile.contents)) {
688 setExternalFile(editableFileContents(environmentId, cwd, relativePath, contents));
689 setEditedContents(null);
690 }
691 const { ready: editable, onPostRender: onEditablePostRender } =
692 useEditableAfterHighlight(externalFile);
693 const editorRef = useRef<Editor<"file", FileCommentAnnotationGroup, undefined> | null>(null);
694 const editorOptions = useMemo<EditorOptions<"file", FileCommentAnnotationGroup, undefined>>(
695 () => ({
696 onAttach: (editor) => {
697 editorRef.current = editor;
698 },
699 onComplete: () => {
700 editorRef.current = null;
701 },
702 }),
703 [],
633704 );
634705
635 useEffect(
636 () => () => {
637 editor.cleanUp();
706 // Mirrors the draft out to the save queue, the optimistic file query and the
707 // composer's review comments.
708 const handleEditChange = useCallback(
709 ({
710 file,
711 lineAnnotations: nextLineAnnotations,
712 }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => {
713 // Adopting an external change reports it as an edit; it is already on disk.
714 if (file.contents === getProjectFileContents(environmentId, cwd, relativePath)) return;
715 setEditedContents(file.contents);
716 setProjectFileQueryData(environmentId, cwd, relativePath, file.contents);
717 saveCoordinator.change(file.contents);
718 if (!nextLineAnnotations) return;
719 const remapped = remapFileCommentAnnotations(nextLineAnnotations);
720 // The editor hands back the array it was given until an edit moves an annotation.
721 setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped));
722 for (const annotation of remapped) {
723 for (const entry of annotation.metadata.entries) {
724 if (entry.kind !== "comment") continue;
725 addReviewComment(
726 composerDraftTarget,
727 buildFileReviewComment({
728 id: entry.id,
729 filePath: relativePath,
730 startLine: entry.startLine,
731 endLine: entry.endLine,
732 text: entry.text,
733 contents: file.contents,
734 }),
735 );
736 }
737 }
638738 },
639 [editor],
739 [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator],
640740 );
641741
642742 const removeAnnotationEntry = useCallback(
......@@ -695,47 +795,44 @@ function EditableFileSurface({
695795 ],
696796 );
697797
698 const beginComment = useCallback(
699 (range: SelectedLineRange) => {
700 editor.setSelections([]);
701 editor.blur();
702 const { startLine, endLine } = normalizeFileCommentRange(range);
703 const draftEntry: FileCommentAnnotationEntry = {
704 id: nextFileCommentId(),
705 kind: "draft",
706 startLine,
707 endLine,
708 text: "",
709 };
710 setLineAnnotations((current) => {
711 const withoutDraft = current.flatMap((annotation) => {
712 const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft");
713 return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];
714 });
715 const existingIndex = withoutDraft.findIndex(
716 (annotation) => annotation.lineNumber === endLine,
717 );
718 if (existingIndex < 0) {
719 return [
720 ...withoutDraft,
721 {
722 lineNumber: endLine,
723 metadata: { entries: [draftEntry] },
724 },
725 ];
726 }
727 return withoutDraft.map((annotation, index) =>
728 index === existingIndex
729 ? {
730 ...annotation,
731 metadata: { entries: [...annotation.metadata.entries, draftEntry] },
732 }
733 : annotation,
734 );
798 const beginComment = useCallback((range: SelectedLineRange) => {
799 editorRef.current?.setSelections([]);
800 editorRef.current?.blur();
801 const { startLine, endLine } = normalizeFileCommentRange(range);
802 const draftEntry: FileCommentAnnotationEntry = {
803 id: nextFileCommentId(),
804 kind: "draft",
805 startLine,
806 endLine,
807 text: "",
808 };
809 setLineAnnotations((current) => {
810 const withoutDraft = current.flatMap((annotation) => {
811 const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft");
812 return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];
735813 });
736 },
737 [editor],
738 );
814 const existingIndex = withoutDraft.findIndex(
815 (annotation) => annotation.lineNumber === endLine,
816 );
817 if (existingIndex < 0) {
818 return [
819 ...withoutDraft,
820 {
821 lineNumber: endLine,
822 metadata: { entries: [draftEntry] },
823 },
824 ];
825 }
826 return withoutDraft.map((annotation, index) =>
827 index === existingIndex
828 ? {
829 ...annotation,
830 metadata: { entries: [...annotation.metadata.entries, draftEntry] },
831 }
832 : annotation,
833 );
834 });
835 }, []);
739836 const hasOpenCommentForm = lineAnnotations.some((annotation) =>
740837 annotation.metadata.entries.some((entry) => entry.kind === "draft"),
741838 );
......@@ -744,11 +841,11 @@ function EditableFileSurface({
744841 if (!root) return;
745842 return installFileEditorDismissal({
746843 root,
747 editor,
844 editor: { setSelections: (selections) => editorRef.current?.setSelections(selections) },
748845 isBlocked: () => hasOpenCommentForm,
749846 onDismiss: () => setSelectedRange(null),
750847 });
751 }, [editor, hasOpenCommentForm, setSelectedRange]);
848 }, [hasOpenCommentForm, setSelectedRange]);
752849 const handleLineSelectionEnd = useCallback(
753850 (range: SelectedLineRange | null) => {
754851 setSelectedRange(range);
......@@ -762,6 +859,7 @@ function EditableFileSurface({
762859 const handlePostRender = useCallback<FilePostRender>(
763860 (fileContainer, instance, phase) => {
764861 onPostRender(fileContainer, instance, phase);
862 onEditablePostRender(instance.file, phase);
765863
766864 if (selectionFrameRef.current !== null) {
767865 cancelAnimationFrame(selectionFrameRef.current);
......@@ -775,11 +873,11 @@ function EditableFileSurface({
775873 instance.setSelectedLines(selectedRange, { notify: false });
776874 });
777875 },
778 [onPostRender, selectedRange],
876 [onEditablePostRender, onPostRender, selectedRange],
779877 );
780878
781879 return (
782 <EditProvider editor={editor}>
880 <EditProvider createEditor={createFileEditor}>
783881 <div ref={surfaceRef} className="flex min-h-0 flex-1">
784882 <Virtualizer
785883 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
......@@ -789,17 +887,10 @@ function EditableFileSurface({
789887 }}
790888 >
791889 <File<FileCommentAnnotationGroup>
792 file={{
793 name: relativePath,
794 contents,
795 cacheKey: projectFileEditorCacheKey(
796 environmentId,
797 cwd,
798 relativePath,
799 contents,
800 editor.getFile(),
801 ),
802 }}
890 file={externalFile}
891 edit={editable}
892 editorOptions={editorOptions}
893 onEditChange={handleEditChange}
803894 options={{
804895 disableFileHeader: true,
805896 enableGutterUtility: !hasOpenCommentForm,
......@@ -832,7 +923,6 @@ function EditableFileSurface({
832923 </div>
833924 )}
834925 className="min-h-full"
835 contentEditable
836926 />
837927 </Virtualizer>
838928 </div>
apps/web/src/components/files/ReadOnlySourcePreview.tsx+1-1
......@@ -17,7 +17,7 @@ export default function ReadOnlySourcePreview(props: {
1717 readonly name: string;
1818 readonly text: string;
1919 readonly cacheKey?: string;
20 readonly onPostRender?: FileOptions<unknown>["onPostRender"];
20 readonly onPostRender?: FileOptions<unknown, undefined>["onPostRender"];
2121}) {
2222 const { resolvedTheme } = useTheme();
2323 const wordWrap = useClientSettings((settings) => settings.wordWrap);
apps/web/src/components/files/fileContentRevision.test.ts+1-24
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
3import { projectFileCacheKey } from "./fileContentRevision";
44
55describe("file cache identity", () => {
66 it("changes for same-length edits", () => {
......@@ -8,27 +8,4 @@ describe("file cache identity", () => {
88 projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"),
99 );
1010 });
11
12 it("keeps editor identity stable for locally edited contents", () => {
13 const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "after", undefined);
14
15 expect(
16 projectFileEditorCacheKey("local", "/repo", "file.json", "after edit", {
17 cacheKey,
18 contents: "after edit",
19 }),
20 ).toBe(cacheKey);
21 });
22
23 it("rotates editor identity for external contents and environments", () => {
24 const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "before", undefined);
25 const editorFile = { cacheKey, contents: "before" };
26
27 expect(
28 projectFileEditorCacheKey("local", "/repo", "file.json", "external edit", editorFile),
29 ).not.toBe(cacheKey);
30 expect(projectFileEditorCacheKey("remote", "/repo", "file.json", "before", undefined)).not.toBe(
31 cacheKey,
32 );
33 });
3411});
apps/web/src/components/files/fileContentRevision.ts-18
......@@ -10,21 +10,3 @@ function fileContentRevision(contents: string): string {
1010export function projectFileCacheKey(cwd: string, relativePath: string, contents: string): string {
1111 return `${cwd}:${relativePath}:${fileContentRevision(contents)}`;
1212}
13
14interface EditorFileIdentity {
15 readonly cacheKey?: string;
16 readonly contents: string;
17}
18
19export function projectFileEditorCacheKey(
20 environmentId: string,
21 cwd: string,
22 relativePath: string,
23 contents: string,
24 editorFile: EditorFileIdentity | undefined,
25): string {
26 if (editorFile?.contents === contents && editorFile.cacheKey) {
27 return editorFile.cacheKey;
28 }
29 return `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`;
30}
apps/web/src/components/files/fileEditorHighlight.test.ts deleted-299
......@@ -1,299 +0,0 @@
1import {
2 FileRenderer,
3 getSharedHighlighter,
4 type BaseCodeOptions,
5 type DiffsHighlighter,
6 type FileContents,
7 type HighlightedToken,
8 type RenderRange,
9} from "@pierre/diffs";
10import { TextDocument } from "@pierre/diffs/editor";
11import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
12import * as NodeWorkerThreads from "node:worker_threads";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
14
15type DocumentChange = NonNullable<ReturnType<TextDocument<unknown>["applyEdits"]>>;
16interface Tokenizer {
17 readonly themeType: "light" | "dark";
18 tokenize(change: DocumentChange, range: RenderRange): Map<number, HighlightedToken[]>;
19 cleanUp(): void;
20}
21
22// This dependency-internal tokenizer is the one used by Editor.#rerender.
23const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs"));
24const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as {
25 EditorTokenizer: new (options: {
26 codeOptions: BaseCodeOptions;
27 highlighter: DiffsHighlighter;
28 textDocument: TextDocument<unknown>;
29 setStyle: (style: string) => void;
30 onDeferTokenize: (lines: Map<number, HighlightedToken[]>, theme: "light" | "dark") => void;
31 }) => Tokenizer;
32};
33
34const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js");
35const source = Array.from(
36 { length: 7_000 },
37 (_, index) =>
38 `export const section${index} = <p>Long wrapped source line ${index} for the file editor.</p>;`,
39).join("\n");
40const options = {
41 theme: "pierre-dark",
42 themeType: "dark",
43 preferredHighlighter: "shiki-wasm",
44 useTokenTransformer: true,
45 overflow: "wrap",
46 disableFileHeader: true,
47} as const;
48const range: RenderRange = {
49 startingLine: 6_950,
50 totalLines: 150,
51 bufferBefore: 0,
52 bufferAfter: 0,
53};
54
55interface HeldResponse {
56 data: WorkerResponse;
57 deliver: () => void;
58}
59let responses: HeldResponse[];
60let responseWaiters: ((response: HeldResponse) => void)[];
61let terminationPromises: Promise<number>[];
62let pool: WorkerPoolManager;
63let renderer: FileRenderer;
64let tokenizer: Tokenizer;
65let file: FileContents;
66let document: TextDocument<unknown>;
67const animationFrames = new Set<ReturnType<typeof setImmediate>>();
68
69function nextResponse(): Promise<HeldResponse> {
70 const response = responses.shift();
71 return response
72 ? Promise.resolve(response)
73 : new Promise((resolve) => responseWaiters.push(resolve));
74}
75
76class WorkerTransport {
77 private readonly worker = new NodeWorkerThreads.Worker(
78 `const { parentPort, workerData } = require("node:worker_threads");
79 globalThis.self = {
80 addEventListener(type, listener) {
81 if (type === "message") parentPort.on("message", data => listener({ data }));
82 if (type === "error") process.on("uncaughtException", listener);
83 }
84 };
85 globalThis.postMessage = data => parentPort.postMessage(data);
86 import(workerData.moduleUrl);`,
87 { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] },
88 );
89
90 addEventListener(
91 type: "message" | "error",
92 listener: (event: { data: WorkerResponse } | Error) => void,
93 ) {
94 if (type === "error") {
95 this.worker.on("error", listener);
96 return;
97 }
98 this.worker.on("message", (data: WorkerResponse) => {
99 const response = { data, deliver: () => listener({ data }) };
100 if (data.type !== "success" || data.requestType !== "file") {
101 response.deliver();
102 return;
103 }
104 const waiter = responseWaiters.shift();
105 if (waiter) waiter(response);
106 else responses.push(response);
107 });
108 }
109
110 postMessage(message: WorkerRequest) {
111 this.worker.postMessage(message, []);
112 }
113
114 terminate() {
115 terminationPromises.push(this.worker.terminate());
116 }
117}
118
119function applyChange(change: DocumentChange) {
120 // Keep the installed editor's non-DOM order, including the existing contents patch.
121 renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType);
122 file.contents = document.getText();
123 if (change.lineDelta !== 0) renderer.applyDocumentChange(document);
124}
125
126function append(text: string) {
127 const position = document.positionAt(document.getText().length);
128 const change = document.applyEdits([
129 { range: { start: position, end: position }, newText: text },
130 ]);
131 expect(change).toBeDefined();
132 applyChange(change!);
133}
134
135function undo() {
136 const change = document.undo()?.[0];
137 expect(change).toBeDefined();
138 applyChange(change!);
139}
140
141function renderContents() {
142 const result = renderer.renderFile(file, range);
143 expect(result?.totalLines).toBe(document.lineCount);
144 return renderer.renderFullHTML(result!);
145}
146
147beforeEach(async () => {
148 responses = [];
149 responseWaiters = [];
150 terminationPromises = [];
151 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
152 const frame = setImmediate(() => {
153 animationFrames.delete(frame);
154 callback(0);
155 });
156 animationFrames.add(frame);
157 return frame;
158 });
159 vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType<typeof setImmediate>) => {
160 animationFrames.delete(frame);
161 clearImmediate(frame);
162 });
163 vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
164 pool = new WorkerPoolManager(
165 // Adapt browser transport only; Pierre's real worker produces each response.
166 { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 },
167 options,
168 );
169 await pool.initialize(["tsx"]);
170 const highlighter = await getSharedHighlighter({
171 themes: ["pierre-dark"],
172 langs: ["tsx"],
173 preferredHighlighter: "shiki-wasm",
174 });
175 file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" };
176 document = new TextDocument(file.name, source, "tsx");
177 renderer = new FileRenderer(options, () => {}, pool);
178 tokenizer = new EditorTokenizer({
179 codeOptions: options,
180 highlighter,
181 textDocument: document,
182 setStyle: () => {},
183 onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme),
184 });
185 renderContents();
186});
187
188async function cleanUpFixture() {
189 tokenizer?.cleanUp();
190 renderer?.cleanUp();
191 pool?.terminate();
192 await Promise.all(terminationPromises);
193 // Pool termination can queue a final broadcast after its workers have exited.
194 for (const frame of animationFrames) clearImmediate(frame);
195 animationFrames.clear();
196 vi.unstubAllGlobals();
197}
198
199afterEach(cleanUpFixture);
200
201describe("editable file highlighting", () => {
202 it("cleans up an already terminated worker pool", async () => {
203 (await nextResponse()).deliver();
204 expect(pool.getStats().totalWorkers).toBe(1);
205 pool.terminate();
206 await Promise.all(terminationPromises);
207 expect(pool.getStats().totalWorkers).toBe(0);
208
209 const animationFrame = globalThis.requestAnimationFrame;
210 const cancelFrame = globalThis.cancelAnimationFrame;
211 const window = globalThis.window;
212 try {
213 await cleanUpFixture();
214 // Deliver the real Immediate queue after cleanup has removed the browser globals.
215 await new Promise<void>((resolve) => setImmediate(resolve));
216 } finally {
217 vi.stubGlobal("requestAnimationFrame", animationFrame);
218 vi.stubGlobal("cancelAnimationFrame", cancelFrame);
219 vi.stubGlobal("window", window);
220 }
221 });
222
223 it("still accepts an asynchronous highlight when the file has not changed", async () => {
224 expect(renderContents()).not.toContain('style="color:');
225 (await nextResponse()).deliver();
226 expect(renderContents()).toContain('style="color:');
227 expect(pool.getFileResultCache(file)).toBeDefined();
228 });
229
230 it.each([1, 60])(
231 "ignores a dispatched highlight after %i Enter edits and highlights the new version",
232 async (count) => {
233 const oldResponse = await nextResponse();
234 for (let index = 0; index < count; index += 1) append("\n");
235 append("export const EDITED_MARKER = 1;");
236 oldResponse.deliver();
237 expect(renderContents()).toContain("EDITED_MARKER");
238 const currentResponse = await nextResponse();
239 currentResponse.deliver();
240 expect(renderContents()).toContain("EDITED_MARKER");
241 const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 });
242 expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:');
243 expect(document.lineCount).toBe(7_000 + count);
244 },
245 );
246
247 it("does not replace a same-line edit with stale tokens", async () => {
248 const oldResponse = await nextResponse();
249 append(" EDITED_MARKER");
250 oldResponse.deliver();
251 expect(renderContents()).toContain("EDITED_MARKER");
252 expect(document.lineCount).toBe(7_000);
253 (await nextResponse()).deliver();
254 expect(renderContents()).toContain("EDITED_MARKER");
255 });
256
257 it("keeps undo edits after an older highlight arrives", async () => {
258 const oldResponse = await nextResponse();
259 append("\nexport const RETAINED_MARKER = 1;");
260 append("\nexport const UNDONE_MARKER = 2;");
261 undo();
262 oldResponse.deliver();
263 const html = renderContents();
264 expect(html).toContain("RETAINED_MARKER");
265 expect(html).not.toContain("UNDONE_MARKER");
266 (await nextResponse()).deliver();
267 expect(renderContents()).toContain("RETAINED_MARKER");
268 undo();
269 expect(document.getText()).toBe(source);
270 expect(renderContents()).not.toContain("RETAINED_MARKER");
271 const redone = document.redo()?.[0];
272 expect(redone).toBeDefined();
273 applyChange(redone!);
274 expect(renderContents()).toContain("RETAINED_MARKER");
275 });
276
277 it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => {
278 (await nextResponse()).deliver();
279 expect(pool.getFileResultCache(file)).toBeDefined();
280 append("\nexport const EDITED_MARKER = 1;");
281 expect(pool.getFileResultCache(file)).toBeUndefined();
282 expect(renderContents()).toContain("EDITED_MARKER");
283 const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 });
284 expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:');
285 });
286
287 it("reopens the edited file with the same cache key while an old response is pending", async () => {
288 const oldResponse = await nextResponse();
289 append("\nexport const REOPENED_MARKER = 1;");
290 renderer.cleanUp();
291 renderer = new FileRenderer(options, () => {}, pool);
292 file = { ...file };
293 expect(renderContents()).toContain("REOPENED_MARKER");
294 oldResponse.deliver();
295 (await nextResponse()).deliver();
296 expect(renderContents()).toContain("REOPENED_MARKER");
297 expect(renderContents()).toContain('style="color:');
298 });
299});
apps/web/src/components/files/fileEditorLanguageReadiness.test.ts deleted-199
......@@ -1,199 +0,0 @@
1import {
2 FileRenderer,
3 disposeHighlighter,
4 getSharedHighlighter,
5 type BaseCodeOptions,
6 type DiffsHighlighter,
7 type FileContents,
8 type HighlightedToken,
9} from "@pierre/diffs";
10import { TextDocument } from "@pierre/diffs/editor";
11import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
12import * as NodeWorkerThreads from "node:worker_threads";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
14
15type DocumentChange = NonNullable<ReturnType<TextDocument<unknown>["applyEdits"]>>;
16interface Tokenizer {
17 tokenize(change: DocumentChange): Map<number, HighlightedToken[]>;
18 cleanUp(): void;
19}
20
21const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs"));
22const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as {
23 EditorTokenizer: new (options: {
24 codeOptions: BaseCodeOptions;
25 highlighter: DiffsHighlighter;
26 textDocument: TextDocument<unknown>;
27 setStyle: (style: string) => void;
28 onDeferTokenize: () => void;
29 }) => Tokenizer;
30};
31
32const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js");
33const options = {
34 theme: "pierre-dark",
35 themeType: "dark",
36 preferredHighlighter: "shiki-wasm",
37 useTokenTransformer: true,
38} as const;
39const source = "export const View = () => <div>Ready</div>;";
40let pool: WorkerPoolManager;
41let renderer: FileRenderer;
42let terminationPromises: Promise<number>[];
43const pendingAnimationFrames = new Set<ReturnType<typeof setImmediate>>();
44
45class WorkerTransport {
46 private readonly worker = new NodeWorkerThreads.Worker(
47 `const { parentPort, workerData } = require("node:worker_threads");
48 globalThis.self = {
49 addEventListener(type, listener) {
50 if (type === "message") parentPort.on("message", data => listener({ data }));
51 if (type === "error") process.on("uncaughtException", listener);
52 }
53 };
54 globalThis.postMessage = data => parentPort.postMessage(data);
55 import(workerData.moduleUrl);`,
56 { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] },
57 );
58
59 addEventListener(
60 type: "message" | "error",
61 listener: (event: { data: WorkerResponse } | Error) => void,
62 ) {
63 if (type === "error") this.worker.on("error", listener);
64 else this.worker.on("message", (data: WorkerResponse) => listener({ data }));
65 }
66
67 postMessage(message: WorkerRequest) {
68 this.worker.postMessage(message, []);
69 }
70
71 terminate() {
72 terminationPromises.push(this.worker.terminate());
73 }
74}
75
76function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) {
77 const document = new TextDocument(file.name, file.contents, language);
78 const tokenizer = new EditorTokenizer({
79 codeOptions: options,
80 highlighter,
81 textDocument: document,
82 setStyle: () => {},
83 onDeferTokenize: () => {},
84 });
85 try {
86 const end = document.positionAt(file.contents.length);
87 const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]);
88 expect(change).toBeDefined();
89 // This is the synchronous first edit, before the tokenizer's debounced prebuild.
90 const dirtyLines = tokenizer.tokenize(change!);
91 expect([...dirtyLines.keys()]).toEqual([0, 1]);
92 expect(document.getText()).toBe(`${file.contents}\n`);
93 } finally {
94 tokenizer.cleanUp();
95 }
96}
97
98beforeEach(async () => {
99 terminationPromises = [];
100 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
101 const handle = setImmediate(() => {
102 pendingAnimationFrames.delete(handle);
103 callback(0);
104 });
105 pendingAnimationFrames.add(handle);
106 return handle;
107 });
108 vi.stubGlobal("cancelAnimationFrame", (handle: ReturnType<typeof setImmediate>) => {
109 pendingAnimationFrames.delete(handle);
110 clearImmediate(handle);
111 });
112 vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
113 await disposeHighlighter();
114 pool = new WorkerPoolManager(
115 // Adapt transport only. The installed Pierre worker resolves and highlights the file.
116 { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 },
117 options,
118 );
119 await pool.initialize();
120 renderer = new FileRenderer(options, undefined, pool);
121});
122
123afterEach(async () => {
124 renderer?.cleanUp();
125 pool?.terminate();
126 await Promise.all(terminationPromises);
127 await disposeHighlighter();
128 // Drain the pool's final state broadcast before removing the animation frame stubs.
129 await new Promise<void>((resolve) => setImmediate(resolve));
130 for (const handle of pendingAnimationFrames) clearImmediate(handle);
131 pendingAnimationFrames.clear();
132 vi.unstubAllGlobals();
133});
134
135describe("editable file language readiness", () => {
136 it.each(["hydrate", "renderFile"] as const)(
137 "%s prepares the inferred language before the first edit of a worker-highlighted file",
138 async (method) => {
139 const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" };
140 await pool.primeFileHighlightCache(file);
141 expect(pool.getFileResultCache(file)).toBeDefined();
142 const mainHighlighter = await getSharedHighlighter({
143 themes: ["pierre-dark"],
144 langs: ["text"],
145 });
146 expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx");
147 renderer[method](file);
148 // Read-only worker rendering must not load editor grammars on the main thread.
149 expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx");
150 const highlighter = await renderer.initializeHighlighter();
151 firstEnter(highlighter, file, "tsx");
152 },
153 );
154
155 it.each(["hydrate", "renderFile"] as const)(
156 "%s respects an explicit language when the filename suggests plain text",
157 async (method) => {
158 const file: FileContents = {
159 name: "source.txt",
160 lang: "tsx",
161 contents: source,
162 cacheKey: "explicit-tsx",
163 };
164 await pool.primeFileHighlightCache(file);
165 renderer[method](file);
166 firstEnter(await renderer.initializeHighlighter(), file, "tsx");
167 },
168 );
169
170 it("loads a newly opened language after reusing a worker-backed renderer", async () => {
171 const previousFile: FileContents = {
172 name: "previous.ts",
173 contents: "export const value = 1;",
174 cacheKey: "previous-ts",
175 };
176 await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] });
177 renderer.renderFile(previousFile);
178 firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript");
179 const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" };
180 renderer.renderFile(nextFile);
181 firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx");
182 });
183
184 it("prepares a hydrated non-worker file even when its theme was already loaded", async () => {
185 renderer.cleanUp();
186 renderer = new FileRenderer(options);
187 const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" };
188 renderer.hydrate(file);
189 firstEnter(await renderer.initializeHighlighter(), file, "tsx");
190 });
191
192 it("keeps plain text editable without loading an unrelated grammar", async () => {
193 const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" };
194 renderer.renderFile(file);
195 const highlighter = await renderer.initializeHighlighter();
196 firstEnter(highlighter, file, "text");
197 expect(highlighter.getLoadedLanguages()).not.toContain("tsx");
198 });
199});
apps/web/src/components/files/fileEditorVirtualization.test.ts+38-18
......@@ -4,7 +4,7 @@ import {
44 Virtualizer,
55 type FileContents,
66} from "@pierre/diffs";
7import { Editor, TextDocument } from "@pierre/diffs/editor";
7import { Editor, TextDocument } from "@pierre/diffs/edit";
88import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
99
1010const renderingManagerUrl = new URL(
......@@ -22,10 +22,21 @@ class MeasuredElement {
2222 children: MeasuredElement[] = [];
2323 dataset: Record<string, string> = {};
2424 nextElementSibling: MeasuredElement | null = null;
25 shadowRoot: MeasuredElement | null = null;
2526 width = 283;
2627
2728 constructor(readonly height = 0) {}
2829
30 attachShadow() {
31 this.shadowRoot ??= new MeasuredElement();
32 return this.shadowRoot;
33 }
34
35 appendChild(child: MeasuredElement) {
36 this.children.push(child);
37 return child;
38 }
39
2940 getBoundingClientRect() {
3041 MeasuredElement.geometryReads += 1;
3142 return { top: 0, height: this.height, width: this.width };
......@@ -108,8 +119,10 @@ class LayoutVirtualizer extends Virtualizer {
108119
109120class MeasuredFile extends VirtualizedFile {
110121 override top = 0;
122 // Rows are measured by hand here; the virtualizer only needs to reconcile them.
123 override onRender = () => false;
111124
112 override attachEditor(editor: Parameters<VirtualizedFile["attachEditor"]>[0]) {
125 override __attachEditor(editor: Parameters<VirtualizedFile["__attachEditor"]>[0]) {
113126 this.editor = editor;
114127 return () => {
115128 this.editor = undefined;
......@@ -117,7 +130,9 @@ class MeasuredFile extends VirtualizedFile {
117130 }
118131
119132 async initialize(file: FileContents) {
120 this.prepareCodeViewItem(file, 0);
133 this.updateCodeViewLayout(file, 0);
134 // Document changes require the session an attached editor installs.
135 (this as unknown as { installEditSession(file: FileContents): void }).installEditSession(file);
121136 await this.fileRenderer.initializeHighlighter();
122137 expect(
123138 this.fileRenderer.renderFile(file, {
......@@ -180,7 +195,7 @@ class MeasuredFile extends VirtualizedFile {
180195}
181196
182197const instances: MeasuredFile[] = [];
183const editors: Editor<undefined>[] = [];
198const editors: Editor<"file", undefined, undefined>[] = [];
184199
185200beforeAll(async () => {
186201 await getSharedHighlighter({
......@@ -225,7 +240,7 @@ async function makeFixture(
225240 cacheKey: `wrapped:${overflow}`,
226241 lang: "text",
227242 };
228 const document = new TextDocument(file.name, contents, "text");
243 const document = new TextDocument<"file", undefined>(file.name, contents, "text");
229244 const instance = new MeasuredFile(
230245 {
231246 overflow,
......@@ -274,7 +289,7 @@ describe("wrapped editor document changes", () => {
274289 const before = instance.getLinePosition(previousLastLine);
275290 expect(before).toEqual({ top: 120328, height: 60 });
276291 const viewport = { top: before!.top - 100, bottom: before!.top + 80 };
277 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 });
292 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 });
278293
279294 append();
280295
......@@ -282,7 +297,7 @@ describe("wrapped editor document changes", () => {
282297 expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 });
283298 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 });
284299 expect(instance.getVirtualizedHeight()).toBe(120376);
285 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 });
300 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 });
286301 });
287302
288303 it("invalidates changed and shifted rows after an insertion in the middle", async () => {
......@@ -373,7 +388,7 @@ describe("wrapped editor document changes", () => {
373388 const { instance, file, append } = await makeFixture();
374389 append();
375390 instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 });
376 instance.prepareCodeViewItem(file, 0);
391 instance.updateCodeViewLayout(file, 0);
377392 expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 });
378393 });
379394
......@@ -381,7 +396,7 @@ describe("wrapped editor document changes", () => {
381396 const { instance, file, append } = await makeFixture();
382397 append();
383398 instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]);
384 instance.prepareCodeViewItem(file, 0);
399 instance.updateCodeViewLayout(file, 0);
385400 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
386401 });
387402});
......@@ -476,11 +491,11 @@ describe("wrapped measurement widths", () => {
476491 "document",
477492 Object.assign(new EditorElement(), { createElement: () => new EditorElement() }),
478493 );
479 const first = new Editor<undefined>();
494 const first = new Editor("file");
480495 editors.push(first);
481496 first.edit(instance);
482497 first.cleanUp();
483 const second = new Editor<undefined>();
498 const second = new Editor("file");
484499 editors.push(second);
485500 second.edit(instance);
486501 instance.resizeContent(482.25);
......@@ -531,12 +546,16 @@ class EditorElement extends MeasuredElement {
531546 style: Record<string, string> = {};
532547 parentElement: EditorElement | null = null;
533548
534 appendChild(child: EditorElement) {
549 override appendChild(child: EditorElement) {
535550 child.parentElement = this;
536551 this.children.push(child);
537552 return child;
538553 }
539554
555 append(child: EditorElement) {
556 this.appendChild(child);
557 }
558
540559 prepend(child: EditorElement) {
541560 child.parentElement = this;
542561 this.children.unshift(child);
......@@ -630,14 +649,15 @@ async function makeEditorFixture(lineCount: number) {
630649 langs: ["text"],
631650 preferredHighlighter: "shiki-wasm",
632651 });
633 const editor = new Editor<undefined>();
652 const editor = new Editor("file");
634653 editors.push(editor);
635654 editor.edit(instance);
636 editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, {
637 startingLine: 0,
638 totalLines: 1,
639 bufferBefore: 0,
640 bufferAfter: 0,
655 editor.__syncRenderView({
656 highlighter,
657 fileContainer: measuredElement(host),
658 file,
659 lineAnnotations: undefined,
660 renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 },
641661 });
642662 const append = (count: number) => {
643663 const lines = editor.getText().split("\n");
apps/web/src/components/files/projectFilesQueryState.ts+12
......@@ -89,6 +89,18 @@ export function getOptimisticProjectFileQueryData(
8989 return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null;
9090}
9191
92/** The contents the Files panel shows, read outside React so it is current within a frame. */
93export function getProjectFileContents(
94 environmentId: EnvironmentId,
95 cwd: string,
96 relativePath: string,
97): string | undefined {
98 const optimistic = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath);
99 if (optimistic) return optimistic.contents;
100 const result = appAtomRegistry.get(getProjectFileQueryAtom(environmentId, cwd, relativePath));
101 return Option.getOrUndefined(AsyncResult.value(result))?.contents;
102}
103
92104export function confirmProjectFileQueryData(
93105 environmentId: EnvironmentId,
94106 cwd: string,
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+3-1
......@@ -254,7 +254,9 @@ function PullRequestCodeTab({
254254 readonly slices: ReadonlyArray<DiffSlice>;
255255 }>({ key: "", cursor: null, slices: NO_SLICES });
256256 const parseCache = useRef(new Map<string, RenderablePatch>());
257 const [viewer, setViewer] = useState<CodeViewHandle<ReviewAnnotationGroup> | null>(null);
257 const [viewer, setViewer] = useState<CodeViewHandle<ReviewAnnotationGroup, undefined> | null>(
258 null,
259 );
258260
259261 const referenceKey = pullRequestReviewKey(reference);
260262 const commit = selectedCommitOid;
apps/web/src/lib/diffRendering.test.ts+4
......@@ -402,6 +402,10 @@ describe("a file whose name a patch header cannot carry plainly", () => {
402402 expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt");
403403 });
404404
405 it("keeps a backslash the name really has", () => {
406 expect(pathOf(quotedPatch("back\\\\slash.txt"))).toBe("back\\slash.txt");
407 });
408
405409 it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => {
406410 expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts");
407411 });
apps/web/src/lib/diffRendering.ts+4-11
......@@ -1,7 +1,6 @@
11import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
22import { parseDiffFromFile } from "@pierre/diffs";
33import type { FileDiffMetadata } from "@pierre/diffs/types";
4import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
54
65const DIFF_THEME_NAMES = {
76 light: "pierre-light",
......@@ -201,17 +200,11 @@ export function getRenderablePatch(
201200}
202201
203202/**
204 * What the patch called the file, as the file's own name. Git writes a name holding a tab, a
205 * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still
206 * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path,
207 * and the host knows the file only under the name it really has.
203 * The file's own name. Git quotes and escapes a name holding a tab, a newline, a quote or a
204 * backslash, and the parser decodes it, so this is the name the host knows the file by.
208205 */
209function fileDiffPath(raw: string): string {
210 return unquoteGitPatchPath(raw);
211}
212
213206export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
214 return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? "");
207 return fileDiff.name ?? fileDiff.prevName ?? "";
215208}
216209
217210/**
......@@ -219,7 +212,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
219212 * path, and the hosts that resolve a diff position against both sides need both names.
220213 */
221214export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string {
222 return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? "");
215 return fileDiff.prevName ?? fileDiff.name ?? "";
223216}
224217
225218/**
apps/web/vite.config.ts+1-1
......@@ -190,7 +190,7 @@ export default defineConfig(() => {
190190 "@clerk/clerk-js",
191191 "@clerk/react/internal",
192192 "@pierre/diffs",
193 "@pierre/diffs/editor",
193 "@pierre/diffs/edit",
194194 "@pierre/diffs/react",
195195 "@pierre/diffs/worker/worker.js",
196196 "effect/Array",
patches/@pierre%2Fdiffs@1.3.0-beta.10.patch deleted-238
......@@ -1,238 +0,0 @@
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);
77diff --git a/dist/editor/editor.js b/dist/editor/editor.js
78index ff78e2a..f9df318 100644
79--- a/dist/editor/editor.js
80+++ b/dist/editor/editor.js
81@@ -146,14 +146,11 @@ var Editor = class {
82 const file = fileInstance.__getCurrentFile?.();
83 if (file !== void 0) requirePersistedCacheKey(file);
84 }
85- const { useTokenTransformer, enableGutterUtility, enableLineSelection, lineHoverHighlight = "disabled", ...rest } = fileInstance.options;
86- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled") {
87+ const { useTokenTransformer, ...rest } = fileInstance.options;
88+ if (useTokenTransformer !== true) {
89 fileInstance.setOptions({
90 ...rest,
91- useTokenTransformer: true,
92- enableGutterUtility: false,
93- enableLineSelection: false,
94- lineHoverHighlight: "disabled"
95+ useTokenTransformer: true
96 });
97 fileInstance.rerender();
98 }
99@@ -908,6 +905,7 @@ var Editor = class {
100 return lineNumber - 1;
101 };
102 this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => {
103+ if (this.#fileInstance?.options.enableLineSelection === true) return;
104 const gutterRow = resolveGutterTarget(e.composedPath()[0]);
105 if (gutterRow?.dataset.lineType === "change-deletion") {
106 const code = gutterRow.closest("[data-code]");
107@@ -1522,6 +1520,12 @@ var Editor = class {
108 if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow;
109 }
110 fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange);
111+ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText();
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+ }
119 if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache();
120 if (newLineAnnotations !== void 0) {
121@@ -1788,6 +1787,7 @@ var Editor = class {
122 }
123 }
124 #setSelectedLinesSafe(range, lineNumberOnly = false) {
125+ if (this.#fileInstance?.options.controlledSelection === true) return;
126 try {
127 this.#fileInstance?.setSelectedLines(range, {
128 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) {
160diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js
161index e9f62f5..af82a46 100644
162--- a/dist/react/utils/useFileInstance.js
163+++ b/dist/react/utils/useFileInstance.js
164@@ -91,10 +91,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu
165 };
166 if (needsEditorOptions) merged = {
167 ...merged,
168- useTokenTransformer: true,
169- enableGutterUtility: false,
170- enableLineSelection: false,
171- lineHoverHighlight: "disabled"
172+ useTokenTransformer: true
173 };
174 return merged;
175 }
176diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js
177--- a/dist/renderers/FileRenderer.js
178+++ b/dist/renderers/FileRenderer.js
179@@ -107,10 +107,10 @@
180 result: massiveFile ? void 0 : cache?.result,
181 renderRange: void 0
182 };
183+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name);
184 if (this.workerManager?.isWorkingPool() === true) {
185 if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file);
186 } else if (this.highlighter == null) {
187- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name);
188 this.initializeHighlighter();
189 }
190 }
191@@ -163,6 +163,8 @@
192 if (this.renderCache == null) return;
193 const { file, result } = this.renderCache;
194 if (result == null) return;
195+ this.workerManager?.cleanUpTasks(this);
196+ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey);
197 const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0;
198 for (const [line, tokens] of dirtyLines) {
199 if (lineCache != null && line < lineCache.lines.length) {
200@@ -268,6 +270,7 @@
201 const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength());
202 const newContent = !areFilesEqual(file, this.renderCache.file);
203 const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange);
204+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name);
205 if (this.workerManager?.isWorkingPool() === true) {
206 if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) {
207 this.renderCache.file = file;
208@@ -278,7 +281,6 @@
209 }
210 if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file);
211 } else {
212- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name);
213 const hasThemes = this.highlighter != null && areThemesAttached(options.theme);
214 const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang);
215 const canHighlight = !forcePlainText && hasLangs;
216diff --git a/package.json b/package.json
217index ff61c90..1e170e5 100644
218--- a/package.json
219+++ b/package.json
220@@ -55,6 +55,18 @@
221 "./worker/worker-portable.js": {
222 "types": "./dist/worker/worker-portable.d.ts",
223 "import": "./dist/worker/worker-portable.js"
224+ },
225+ "./types": {
226+ "types": "./dist/types.d.ts",
227+ "import": "./dist/types.js"
228+ },
229+ "./utils/getFiletypeFromFileName": {
230+ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
231+ "import": "./dist/utils/getFiletypeFromFileName.js"
232+ },
233+ "./utils/parsePatchFiles": {
234+ "types": "./dist/utils/parsePatchFiles.d.ts",
235+ "import": "./dist/utils/parsePatchFiles.js"
236 }
237 },
238 "publishConfig": {
patches/@pierre%2Fdiffs@1.5.2.patch created+146
......@@ -0,0 +1,146 @@
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@@ -49,7 +49,7 @@
5 setVisibility(visible: boolean): void;
6 rerender(): void;
7 syncGhostTextRows(): void;
8- applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean): void;
9+ applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean, startLine?: number): void;
10 render({ fileContainer, file, forceRender, lineAnnotations, ...props }: FileRenderProps<LAnnotation>): boolean;
11 protected finalizeRender(): void;
12 private updatePendingRender;
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@@ -23,6 +23,7 @@
17 heights: /* @__PURE__ */ new Map(),
18 checkpoints: [],
19 fileAnnotationHeight: 0,
20+ codeWidth: void 0,
21 ghostTextRows: NO_GHOST_TEXT_ROWS
22 };
23 pendingRender;
24@@ -35,6 +36,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 const nextMetrics = computeVirtualFileMetrics(metrics);
33@@ -86,10 +89,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 this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS;
48 if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;
49@@ -135,6 +140,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 if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) {
61 const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;
62 if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) {
63@@ -358,12 +370,12 @@
64 codeView.instanceChanged(this, true);
65 } else this.getSimpleVirtualizer()?.requestHeightReconcile(this);
66 }
67- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) {
68+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) {
69 const { renderRange: previousRenderRange } = this;
70 this.getAdvancedVirtualizer()?.capturePendingLayoutAnchor();
71 super.applyDocumentChange(textDocument, newLineAnnotations);
72 this.getSimpleVirtualizer()?.markDOMDirty();
73- this.resetLayoutCache(this.isSimpleMode(), false);
74+ this.resetLayoutCache(this.isSimpleMode(), false, startLine);
75 const file = this.getRenderedFile();
76 if (!this.isSimpleMode()) this.computeApproximateSize(true);
77 else if (shouldUpdateBuffer && previousRenderRange != null && file != null) {
78diff --git a/dist/editor/editor.js b/dist/editor/editor.js
79--- a/dist/editor/editor.js
80+++ b/dist/editor/editor.js
81@@ -1899,7 +1899,11 @@
82 changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,
83 documentLineCount: textDocument.lineCount
84 });
85- if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer);
86+ if (didLineCountChange) {
87+ // A wider or narrower line-number gutter can rewrap unchanged rows.
88+ const layoutStartLine = String(change.previousLineCount).length === String(change.lineCount).length ? change.startLine : 0;
89+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine);
90+ }
91 if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache();
92 if (newLineAnnotations != null) {
93 this.#lineAnnotations = newLineAnnotations;
94diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
95--- a/dist/managers/ResizeManager.d.ts
96+++ b/dist/managers/ResizeManager.d.ts
97@@ -5,6 +5,8 @@
98 columnVariables?: ResizeManagerColumnVariableMode;
99 }
100 declare class ResizeManager {
101+ /** Schedule owner measurement after an observed code or gutter size change. */
102+ onResize?: () => void;
103 private static resizeObserver;
104 private static managersByElement;
105 private static getResizeObserver;
106diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
107--- a/dist/managers/ResizeManager.js
108+++ b/dist/managers/ResizeManager.js
109@@ -19,6 +19,7 @@
110 for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);
111 }
112 observedNodes = /* @__PURE__ */ new Map();
113+ onResize;
114 setup(pre, { disableAnnotations, columnVariables = "apply" }) {
115 const annotationUpdates = /* @__PURE__ */ new Set();
116 const applyColumnVariables = columnVariables === "apply";
117@@ -212,6 +213,7 @@
118 this.applyAnnotationUpdates(annotationUpdates);
119 annotationUpdates.clear();
120 this.applyColumnUpdates(codeUpdates);
121+ if (codeUpdates.size > 0) this.onResize?.();
122 codeUpdates.clear();
123 }
124 applyAnnotationUpdates(annotationUpdates) {
125diff --git a/package.json b/package.json
126--- a/package.json
127+++ b/package.json
128@@ -58,6 +58,18 @@
129 "./worker/worker-portable.js": {
130 "types": "./dist/worker/worker-portable.d.ts",
131 "import": "./dist/worker/worker-portable.js"
132+ },
133+ "./types": {
134+ "types": "./dist/types.d.ts",
135+ "import": "./dist/types.js"
136+ },
137+ "./utils/getFiletypeFromFileName": {
138+ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
139+ "import": "./dist/utils/getFiletypeFromFileName.js"
140+ },
141+ "./utils/parsePatchFiles": {
142+ "types": "./dist/utils/parsePatchFiles.d.ts",
143+ "import": "./dist/utils/parsePatchFiles.js"
144 }
145 },
146 "publishConfig": {
pnpm-lock.yaml+89-29
......@@ -25,8 +25,8 @@ catalogs:
2525 specifier: 1.8.0
2626 version: 1.8.0
2727 '@pierre/diffs':
28 specifier: 1.3.0-beta.10
29 version: 1.3.0-beta.10
28 specifier: 1.5.2
29 version: 1.5.2
3030 jose:
3131 specifier: 6.2.2
3232 version: 6.2.2
......@@ -103,7 +103,7 @@ patchedDependencies:
103103 '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd
104104 '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685
105105 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59
106 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
106 '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289
107107 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
108108 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
109109 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
......@@ -277,7 +277,7 @@ importers:
277277 version: 1.9.1
278278 '@pierre/diffs':
279279 specifier: 'catalog:'
280 version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
280 version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
281281 '@react-native-ai/apple':
282282 specifier: 0.12.0
283283 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))
......@@ -502,8 +502,8 @@ importers:
502502 specifier: 4.0.1
503503 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
504504 '@pierre/trees':
505 specifier: 1.0.0-beta.4
506 version: 1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
505 specifier: 1.0.0-beta.6
506 version: 1.0.0-beta.6(@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)
507507 '@types/react':
508508 specifier: ~19.3.0
509509 version: 19.3.0
......@@ -666,10 +666,10 @@ importers:
666666 version: 1.8.0
667667 '@pierre/diffs':
668668 specifier: 'catalog:'
669 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)
669 version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
670670 '@pierre/trees':
671 specifier: 1.0.0-beta.4
672 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
671 specifier: 1.0.0-beta.6
672 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)
673673 '@t3tools/client-runtime':
674674 specifier: workspace:*
675675 version: link:../../packages/client-runtime
......@@ -4444,18 +4444,27 @@ packages:
44444444 resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
44454445 engines: {node: '>=14.18.0'}
44464446
4447 '@pierre/diffs@1.3.0-beta.10':
4448 resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==}
4447 '@pierre/diffs@1.5.2':
4448 resolution: {integrity: sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ==}
44494449 peerDependencies:
44504450 react: ^18.3.1 || ^19.0.0
44514451 react-dom: ^18.3.1 || ^19.0.0
4452 peerDependenciesMeta:
4453 react:
4454 optional: true
4455 react-dom:
4456 optional: true
44524457
44534458 '@pierre/theme@1.1.0':
44544459 resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==}
44554460 engines: {vscode: ^1.0.0}
44564461
4457 '@pierre/theming@0.0.2':
4458 resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==}
4462 '@pierre/theme@2.0.0':
4463 resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==}
4464 engines: {vscode: ^1.0.0}
4465
4466 '@pierre/theming@1.0.0':
4467 resolution: {integrity: sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g==}
44594468 peerDependencies:
44604469 '@pierre/theme': ^1.1.0
44614470 '@shikijs/themes': ^3.0.0 || ^4.0.0
......@@ -4474,8 +4483,28 @@ packages:
44744483 shiki:
44754484 optional: true
44764485
4477 '@pierre/trees@1.0.0-beta.4':
4478 resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==}
4486 '@pierre/theming@1.0.1':
4487 resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==}
4488 peerDependencies:
4489 '@pierre/theme': ^1.1.0 || ^2.0.0
4490 '@shikijs/themes': ^3.0.0 || ^4.0.0
4491 react: ^18.3.1 || ^19.0.0
4492 react-dom: ^18.3.1 || ^19.0.0
4493 shiki: ^3.0.0 || ^4.0.0
4494 peerDependenciesMeta:
4495 '@pierre/theme':
4496 optional: true
4497 '@shikijs/themes':
4498 optional: true
4499 react:
4500 optional: true
4501 react-dom:
4502 optional: true
4503 shiki:
4504 optional: true
4505
4506 '@pierre/trees@1.0.0-beta.6':
4507 resolution: {integrity: sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw==}
44794508 peerDependencies:
44804509 react: ^18.3.1 || ^19.0.0
44814510 react-dom: ^18.3.1 || ^19.0.0
......@@ -14869,65 +14898,96 @@ snapshots:
1486914898 tslib: 2.8.1
1487014899 webcrypto-core: 1.9.2
1487114900
14872 '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
14901 '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
1487314902 dependencies:
14874 '@pierre/theme': 1.1.0
14875 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)
14903 '@pierre/theme': 2.0.0
14904 '@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)
1487614905 '@shikijs/transformers': 4.2.0
1487714906 diff: 9.0.0
1487814907 hast-util-to-html: 9.0.5
1487914908 lru_map: 0.4.1
14909 shiki: 4.2.0
14910 optionalDependencies:
1488014911 react: 19.3.0
1488114912 react-dom: 19.3.0(react@19.3.0)
14882 shiki: 4.2.0
1488314913 transitivePeerDependencies:
1488414914 - '@shikijs/themes'
1488514915
14886 '@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)':
14916 '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1488714917 dependencies:
14888 '@pierre/theme': 1.1.0
14889 '@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)
14918 '@pierre/theme': 2.0.0
14919 '@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)
1489014920 '@shikijs/transformers': 4.2.0
1489114921 diff: 9.0.0
1489214922 hast-util-to-html: 9.0.5
1489314923 lru_map: 0.4.1
14924 shiki: 4.2.0
14925 optionalDependencies:
1489414926 react: 19.2.6
1489514927 react-dom: 19.2.6(react@19.2.6)
14896 shiki: 4.2.0
1489714928 transitivePeerDependencies:
1489814929 - '@shikijs/themes'
1489914930
14900 '@pierre/theme@1.1.0': {}
14931 '@pierre/theme@1.1.0':
14932 optional: true
14933
14934 '@pierre/theme@2.0.0': {}
1490114935
14902 '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)':
14936 '@pierre/theming@1.0.0(@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)':
1490314937 optionalDependencies:
1490414938 '@pierre/theme': 1.1.0
14939 '@shikijs/themes': 4.3.0
14940 react: 19.2.6
14941 react-dom: 19.2.6(react@19.2.6)
14942 shiki: 4.2.0
14943
14944 '@pierre/theming@1.0.0(@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)':
14945 optionalDependencies:
14946 '@pierre/theme': 2.0.0
1490514947 '@shikijs/themes': 4.2.0
1490614948 react: 19.3.0
1490714949 react-dom: 19.3.0(react@19.3.0)
1490814950 shiki: 4.2.0
1490914951
14910 '@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)':
14952 '@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)':
1491114953 optionalDependencies:
14912 '@pierre/theme': 1.1.0
14954 '@pierre/theme': 2.0.0
14955 '@shikijs/themes': 4.2.0
14956 react: 19.3.0
14957 react-dom: 19.3.0(react@19.3.0)
14958 shiki: 4.2.0
14959
14960 '@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)':
14961 optionalDependencies:
14962 '@pierre/theme': 2.0.0
1491314963 '@shikijs/themes': 4.3.0
1491414964 react: 19.2.6
1491514965 react-dom: 19.2.6(react@19.2.6)
1491614966 shiki: 4.2.0
1491714967
14918 '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
14968 '@pierre/trees@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)':
1491914969 dependencies:
14970 '@pierre/theming': 1.0.0(@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)
1492014971 preact: 11.0.0-beta.0
1492114972 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
1492214973 react: 19.2.6
1492314974 react-dom: 19.2.6(react@19.2.6)
14975 transitivePeerDependencies:
14976 - '@pierre/theme'
14977 - '@shikijs/themes'
14978 - shiki
1492414979
14925 '@pierre/trees@1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
14980 '@pierre/trees@1.0.0-beta.6(@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)':
1492614981 dependencies:
14982 '@pierre/theming': 1.0.0(@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)
1492714983 preact: 11.0.0-beta.0
1492814984 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
1492914985 react: 19.3.0
1493014986 react-dom: 19.3.0(react@19.3.0)
14987 transitivePeerDependencies:
14988 - '@pierre/theme'
14989 - '@shikijs/themes'
14990 - shiki
1493114991
1493214992 '@polka/url@1.0.0-next.29': {}
1493314993
pnpm-workspace.yaml+4-2
......@@ -41,7 +41,7 @@ catalog:
4141 "@legendapp/list": 3.3.5
4242 "@noble/curves": 1.9.1
4343 "@noble/hashes": 1.8.0
44 "@pierre/diffs": 1.3.0-beta.10
44 "@pierre/diffs": 1.5.2
4545 "@tailwindcss/node": 4.3.3
4646 "@tailwindcss/oxide": 4.3.3
4747 "@tailwindcss/vite": 4.3.3
......@@ -270,7 +270,9 @@ patchedDependencies:
270270 "@opencode/client@2.0.23": patches/@opencode__client@2.0.23.patch
271271 "@opencode/protocol@2.0.23": patches/@opencode__protocol@2.0.23.patch
272272 "@opencode/schema@2.0.23": patches/@opencode__schema@2.0.23.patch
273 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch
273 # Exports the dependency-light subpaths mobile and the web diff parser import, and keeps
274 # measured wrapped-row heights above an edit (fileEditorVirtualization.test.ts).
275 "@pierre/diffs@1.5.2": patches/@pierre%2Fdiffs@1.5.2.patch
274276 "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch
275277 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
276278 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch