| ... | @@ -0,0 +1,264 @@ |
| 1 | import { |
| 2 | FileRenderer, |
| 3 | getSharedHighlighter, |
| 4 | type BaseCodeOptions, |
| 5 | type DiffsHighlighter, |
| 6 | type FileContents, |
| 7 | type HighlightedToken, |
| 8 | type RenderRange, |
| 9 | } from "@pierre/diffs"; |
| 10 | import { TextDocument } from "@pierre/diffs/editor"; |
| 11 | import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; |
| 12 | import * as NodeWorkerThreads from "node:worker_threads"; |
| 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 14 | |
| 15 | type DocumentChange = NonNullable<ReturnType<TextDocument<unknown>["applyEdits"]>>; |
| 16 | interface 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. |
| 23 | const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); |
| 24 | const { 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 | |
| 34 | const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); |
| 35 | const 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"); |
| 40 | const options = { |
| 41 | theme: "pierre-dark", |
| 42 | themeType: "dark", |
| 43 | preferredHighlighter: "shiki-wasm", |
| 44 | useTokenTransformer: true, |
| 45 | overflow: "wrap", |
| 46 | disableFileHeader: true, |
| 47 | } as const; |
| 48 | const range: RenderRange = { |
| 49 | startingLine: 6_950, |
| 50 | totalLines: 150, |
| 51 | bufferBefore: 0, |
| 52 | bufferAfter: 0, |
| 53 | }; |
| 54 | |
| 55 | interface HeldResponse { |
| 56 | data: WorkerResponse; |
| 57 | deliver: () => void; |
| 58 | } |
| 59 | let responses: HeldResponse[]; |
| 60 | let responseWaiters: ((response: HeldResponse) => void)[]; |
| 61 | let terminationPromises: Promise<number>[]; |
| 62 | let pool: WorkerPoolManager; |
| 63 | let renderer: FileRenderer; |
| 64 | let tokenizer: Tokenizer; |
| 65 | let file: FileContents; |
| 66 | let document: TextDocument<unknown>; |
| 67 | |
| 68 | function nextResponse(): Promise<HeldResponse> { |
| 69 | const response = responses.shift(); |
| 70 | return response |
| 71 | ? Promise.resolve(response) |
| 72 | : new Promise((resolve) => responseWaiters.push(resolve)); |
| 73 | } |
| 74 | |
| 75 | class WorkerTransport { |
| 76 | private readonly worker = new NodeWorkerThreads.Worker( |
| 77 | `const { parentPort, workerData } = require("node:worker_threads"); |
| 78 | globalThis.self = { |
| 79 | addEventListener(type, listener) { |
| 80 | if (type === "message") parentPort.on("message", data => listener({ data })); |
| 81 | if (type === "error") process.on("uncaughtException", listener); |
| 82 | } |
| 83 | }; |
| 84 | globalThis.postMessage = data => parentPort.postMessage(data); |
| 85 | import(workerData.moduleUrl);`, |
| 86 | { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, |
| 87 | ); |
| 88 | |
| 89 | addEventListener( |
| 90 | type: "message" | "error", |
| 91 | listener: (event: { data: WorkerResponse } | Error) => void, |
| 92 | ) { |
| 93 | if (type === "error") { |
| 94 | this.worker.on("error", listener); |
| 95 | return; |
| 96 | } |
| 97 | this.worker.on("message", (data: WorkerResponse) => { |
| 98 | const response = { data, deliver: () => listener({ data }) }; |
| 99 | if (data.type !== "success" || data.requestType !== "file") { |
| 100 | response.deliver(); |
| 101 | return; |
| 102 | } |
| 103 | const waiter = responseWaiters.shift(); |
| 104 | if (waiter) waiter(response); |
| 105 | else responses.push(response); |
| 106 | }); |
| 107 | } |
| 108 | |
| 109 | postMessage(message: WorkerRequest) { |
| 110 | this.worker.postMessage(message, []); |
| 111 | } |
| 112 | |
| 113 | terminate() { |
| 114 | terminationPromises.push(this.worker.terminate()); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | function applyChange(change: DocumentChange) { |
| 119 | // Keep the installed editor's non-DOM order, including the existing contents patch. |
| 120 | renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType); |
| 121 | file.contents = document.getText(); |
| 122 | if (change.lineDelta !== 0) renderer.applyDocumentChange(document); |
| 123 | } |
| 124 | |
| 125 | function append(text: string) { |
| 126 | const position = document.positionAt(document.getText().length); |
| 127 | const change = document.applyEdits([ |
| 128 | { range: { start: position, end: position }, newText: text }, |
| 129 | ]); |
| 130 | expect(change).toBeDefined(); |
| 131 | applyChange(change!); |
| 132 | } |
| 133 | |
| 134 | function undo() { |
| 135 | const change = document.undo()?.[0]; |
| 136 | expect(change).toBeDefined(); |
| 137 | applyChange(change!); |
| 138 | } |
| 139 | |
| 140 | function renderContents() { |
| 141 | const result = renderer.renderFile(file, range); |
| 142 | expect(result?.totalLines).toBe(document.lineCount); |
| 143 | return renderer.renderFullHTML(result!); |
| 144 | } |
| 145 | |
| 146 | beforeEach(async () => { |
| 147 | responses = []; |
| 148 | responseWaiters = []; |
| 149 | terminationPromises = []; |
| 150 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => |
| 151 | setImmediate(() => callback(0)), |
| 152 | ); |
| 153 | vi.stubGlobal("cancelAnimationFrame", clearImmediate); |
| 154 | vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); |
| 155 | pool = new WorkerPoolManager( |
| 156 | // Adapt browser transport only; Pierre's real worker produces each response. |
| 157 | { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, |
| 158 | options, |
| 159 | ); |
| 160 | await pool.initialize(["tsx"]); |
| 161 | const highlighter = await getSharedHighlighter({ |
| 162 | themes: ["pierre-dark"], |
| 163 | langs: ["tsx"], |
| 164 | preferredHighlighter: "shiki-wasm", |
| 165 | }); |
| 166 | file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" }; |
| 167 | document = new TextDocument(file.name, source, "tsx"); |
| 168 | renderer = new FileRenderer(options, () => {}, pool); |
| 169 | tokenizer = new EditorTokenizer({ |
| 170 | codeOptions: options, |
| 171 | highlighter, |
| 172 | textDocument: document, |
| 173 | setStyle: () => {}, |
| 174 | onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme), |
| 175 | }); |
| 176 | renderContents(); |
| 177 | }); |
| 178 | |
| 179 | afterEach(async () => { |
| 180 | tokenizer?.cleanUp(); |
| 181 | renderer?.cleanUp(); |
| 182 | pool?.terminate(); |
| 183 | await Promise.all(terminationPromises); |
| 184 | vi.unstubAllGlobals(); |
| 185 | }); |
| 186 | |
| 187 | describe("editable file highlighting", () => { |
| 188 | it("still accepts an asynchronous highlight when the file has not changed", async () => { |
| 189 | expect(renderContents()).not.toContain('style="color:'); |
| 190 | (await nextResponse()).deliver(); |
| 191 | expect(renderContents()).toContain('style="color:'); |
| 192 | expect(pool.getFileResultCache(file)).toBeDefined(); |
| 193 | }); |
| 194 | |
| 195 | it.each([1, 60])( |
| 196 | "ignores a dispatched highlight after %i Enter edits and highlights the new version", |
| 197 | async (count) => { |
| 198 | const oldResponse = await nextResponse(); |
| 199 | for (let index = 0; index < count; index += 1) append("\n"); |
| 200 | append("export const EDITED_MARKER = 1;"); |
| 201 | oldResponse.deliver(); |
| 202 | expect(renderContents()).toContain("EDITED_MARKER"); |
| 203 | const currentResponse = await nextResponse(); |
| 204 | currentResponse.deliver(); |
| 205 | expect(renderContents()).toContain("EDITED_MARKER"); |
| 206 | const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); |
| 207 | expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); |
| 208 | expect(document.lineCount).toBe(7_000 + count); |
| 209 | }, |
| 210 | ); |
| 211 | |
| 212 | it("does not replace a same-line edit with stale tokens", async () => { |
| 213 | const oldResponse = await nextResponse(); |
| 214 | append(" EDITED_MARKER"); |
| 215 | oldResponse.deliver(); |
| 216 | expect(renderContents()).toContain("EDITED_MARKER"); |
| 217 | expect(document.lineCount).toBe(7_000); |
| 218 | (await nextResponse()).deliver(); |
| 219 | expect(renderContents()).toContain("EDITED_MARKER"); |
| 220 | }); |
| 221 | |
| 222 | it("keeps undo edits after an older highlight arrives", async () => { |
| 223 | const oldResponse = await nextResponse(); |
| 224 | append("\nexport const RETAINED_MARKER = 1;"); |
| 225 | append("\nexport const UNDONE_MARKER = 2;"); |
| 226 | undo(); |
| 227 | oldResponse.deliver(); |
| 228 | const html = renderContents(); |
| 229 | expect(html).toContain("RETAINED_MARKER"); |
| 230 | expect(html).not.toContain("UNDONE_MARKER"); |
| 231 | (await nextResponse()).deliver(); |
| 232 | expect(renderContents()).toContain("RETAINED_MARKER"); |
| 233 | undo(); |
| 234 | expect(document.getText()).toBe(source); |
| 235 | expect(renderContents()).not.toContain("RETAINED_MARKER"); |
| 236 | const redone = document.redo()?.[0]; |
| 237 | expect(redone).toBeDefined(); |
| 238 | applyChange(redone!); |
| 239 | expect(renderContents()).toContain("RETAINED_MARKER"); |
| 240 | }); |
| 241 | |
| 242 | it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => { |
| 243 | (await nextResponse()).deliver(); |
| 244 | expect(pool.getFileResultCache(file)).toBeDefined(); |
| 245 | append("\nexport const EDITED_MARKER = 1;"); |
| 246 | expect(pool.getFileResultCache(file)).toBeUndefined(); |
| 247 | expect(renderContents()).toContain("EDITED_MARKER"); |
| 248 | const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); |
| 249 | expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); |
| 250 | }); |
| 251 | |
| 252 | it("reopens the edited file with the same cache key while an old response is pending", async () => { |
| 253 | const oldResponse = await nextResponse(); |
| 254 | append("\nexport const REOPENED_MARKER = 1;"); |
| 255 | renderer.cleanUp(); |
| 256 | renderer = new FileRenderer(options, () => {}, pool); |
| 257 | file = { ...file }; |
| 258 | expect(renderContents()).toContain("REOPENED_MARKER"); |
| 259 | oldResponse.deliver(); |
| 260 | (await nextResponse()).deliver(); |
| 261 | expect(renderContents()).toContain("REOPENED_MARKER"); |
| 262 | expect(renderContents()).toContain('style="color:'); |
| 263 | }); |
| 264 | }); |