@luan.sh/pi-libtui 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,310 @@
1
+ import { type FileDiffMetadata, HUNK_HEADER, parsePatchFiles } from "@pierre/diffs";
2
+ import { sanitizeTuiText } from "../content/terminal-text.ts";
3
+ import type {
4
+ ParseUnifiedDiffOptions,
5
+ UnifiedDiffFile,
6
+ UnifiedDiffHunk,
7
+ UnifiedDiffLine,
8
+ UnifiedDiffModel,
9
+ } from "./model.ts";
10
+ import { DEFAULT_MAX_CHARACTERS, DEFAULT_MAX_FILES, DEFAULT_MAX_ROWS, hash } from "./structured.ts";
11
+
12
+ const PARSE_CACHE_LIMIT = 4;
13
+
14
+ const parseCache = new Map<string, { source: string; model: UnifiedDiffModel }>();
15
+ const pierreFiles = new WeakMap<UnifiedDiffModel, readonly FileDiffMetadata[]>();
16
+
17
+ export { createStructuredDiffModel as createUnifiedDiffModel } from "./structured.ts";
18
+
19
+ /** Parse a unified patch through Pierre's maintained patch parser. */
20
+ export function parseUnifiedDiff(source: string, options: ParseUnifiedDiffOptions = {}): UnifiedDiffModel {
21
+ const maxCharacters = positiveInteger(options.maxCharacters, DEFAULT_MAX_CHARACTERS);
22
+ const maxRows = positiveInteger(options.maxRows, DEFAULT_MAX_ROWS);
23
+ const bounded = boundSource(source, maxCharacters, maxRows);
24
+ const cacheKey = `${maxCharacters}:${maxRows}:${bounded.truncated}:${bounded.rows}:${hash(bounded.source)}`;
25
+ const cached = parseCache.get(cacheKey);
26
+ if (cached?.source === bounded.source) return touch(parseCache, cacheKey, cached).model;
27
+
28
+ const patches = parsePierrePatch(bounded.source, cacheKey);
29
+ const files = patches.flatMap((patch) => patch.files).slice(0, DEFAULT_MAX_FILES);
30
+ const fileSides = patchFileSides(bounded.source);
31
+ const converted = files.map((file, index) => convertFile(file, index, fileSides[index]));
32
+ const preamble = patches.flatMap((patch) =>
33
+ patch.patchMetadata ? patch.patchMetadata.replace(/\n$/u, "").split("\n").map(sanitizeTuiText) : [],
34
+ );
35
+ const model: UnifiedDiffModel = Object.freeze({
36
+ revision: hash(`${bounded.source.length}:${bounded.source}:${bounded.truncated}`),
37
+ files: Object.freeze(converted),
38
+ preamble: Object.freeze(preamble),
39
+ additions: converted.reduce((total, file) => total + file.additions, 0),
40
+ removals: converted.reduce((total, file) => total + file.removals, 0),
41
+ sourceRows: bounded.rows,
42
+ truncated: bounded.truncated || files.length < patches.reduce((total, patch) => total + patch.files.length, 0),
43
+ });
44
+ pierreFiles.set(model, Object.freeze(files));
45
+ return boundedSet(parseCache, cacheKey, { source: bounded.source, model }, PARSE_CACHE_LIMIT).model;
46
+ }
47
+
48
+ function parsePierrePatch(source: string, cacheKey: string): ReturnType<typeof parsePatchFiles> {
49
+ try {
50
+ return parsePatchFiles(source, cacheKey, true);
51
+ } catch {
52
+ // Pierre's tolerant mode keeps valid rows when a caller supplied a range
53
+ // for a bounded preview. Complete only the physical-row boundary here;
54
+ // Pierre still owns hunk ranges, row counts, and row semantics.
55
+ const firstHunk = source.indexOf("\n@@");
56
+ if (firstHunk < 0) return [];
57
+ const lastNewline = source.lastIndexOf("\n");
58
+ const tail = source.slice(lastNewline + 1);
59
+ const hasCompleteRow = ["+", "-", " ", "\\"].some((marker) => tail.startsWith(marker));
60
+ const completeSource = source.endsWith("\n")
61
+ ? source
62
+ : hasCompleteRow
63
+ ? `${source}\n`
64
+ : source.slice(0, lastNewline + 1);
65
+ if (!completeSource) return [];
66
+ return parsePatchFiles(completeSource, cacheKey, false);
67
+ }
68
+ }
69
+
70
+ /** Internal bridge used by the terminal renderer for Pierre/Shiki decoration. */
71
+ export function pierreFilesFor(model: UnifiedDiffModel): readonly FileDiffMetadata[] {
72
+ return pierreFiles.get(model) ?? [];
73
+ }
74
+
75
+ interface PatchFileSides {
76
+ readonly oldMissing: boolean;
77
+ readonly newMissing: boolean;
78
+ }
79
+
80
+ function convertFile(file: FileDiffMetadata, fileIndex: number, sides: PatchFileSides | undefined): UnifiedDiffFile {
81
+ const ref = `f${fileIndex}`;
82
+ const oldPath = normalizePath(sides?.oldMissing || file.type === "new" ? undefined : (file.prevName ?? file.name));
83
+ const newPath = normalizePath(sides?.newMissing || file.type === "deleted" ? undefined : file.name);
84
+ const hunks = file.hunks.map((_, hunkIndex) => convertHunk(file, ref, hunkIndex));
85
+ return Object.freeze({
86
+ ref,
87
+ oldPath,
88
+ newPath,
89
+ headerLines: Object.freeze([
90
+ `--- ${oldPath ? `a/${oldPath}` : "/dev/null"}`,
91
+ `+++ ${newPath ? `b/${newPath}` : "/dev/null"}`,
92
+ ]),
93
+ hunks: Object.freeze(hunks),
94
+ additions: hunks.reduce((total, hunk) => total + hunk.lines.filter((line) => line.kind === "added").length, 0),
95
+ removals: hunks.reduce((total, hunk) => total + hunk.lines.filter((line) => line.kind === "removed").length, 0),
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Pierre normalizes `/dev/null` to an ordinary path for partial patches, so
101
+ * it cannot tell a missing side from a file whose contents happen to be
102
+ * empty. Keep this bridge limited to the file-header markers needed to retain
103
+ * that presentation fact. The `diff` and `@@` checks are only boundaries;
104
+ * Pierre still owns hunk ranges, row classification, and all diff semantics.
105
+ */
106
+ function patchFileSides(source: string): readonly PatchFileSides[] {
107
+ const sides: PatchFileSides[] = [];
108
+ let current: { oldMissing: boolean; newMissing: boolean } | undefined;
109
+ let hasHeader = false;
110
+ let inHunk = false;
111
+ let remainingOldLines = 0;
112
+ let remainingNewLines = 0;
113
+ const finish = () => {
114
+ if (current) sides.push(current);
115
+ current = undefined;
116
+ hasHeader = false;
117
+ };
118
+ const lines = source.split("\n");
119
+ for (let index = 0; index < lines.length; index += 1) {
120
+ const line = lines[index]!;
121
+ if (line.startsWith("diff ")) {
122
+ finish();
123
+ current = { oldMissing: false, newMissing: false };
124
+ inHunk = false;
125
+ remainingOldLines = 0;
126
+ remainingNewLines = 0;
127
+ continue;
128
+ }
129
+ if (!inHunk && isUnifiedFileHeader(lines, index)) {
130
+ if (hasHeader) finish();
131
+ current ??= { oldMissing: false, newMissing: false };
132
+ current.oldMissing = isDevNullHeader(line, "--- ");
133
+ current.newMissing = isDevNullHeader(lines[index + 1]!, "+++ ");
134
+ hasHeader = true;
135
+ index += 1;
136
+ continue;
137
+ }
138
+ if (line.startsWith("@@ ")) {
139
+ const hunk = HUNK_HEADER.exec(line);
140
+ inHunk = true;
141
+ remainingOldLines = hunk ? Number(hunk[2] ?? 1) : 0;
142
+ remainingNewLines = hunk ? Number(hunk[4] ?? 1) : 0;
143
+ if (remainingOldLines === 0 && remainingNewLines === 0) inHunk = false;
144
+ continue;
145
+ }
146
+ if (inHunk) {
147
+ if (!line.startsWith("\\")) {
148
+ if (line.startsWith(" ") || line.startsWith("-")) remainingOldLines = Math.max(0, remainingOldLines - 1);
149
+ if (line.startsWith(" ") || line.startsWith("+")) remainingNewLines = Math.max(0, remainingNewLines - 1);
150
+ }
151
+ if (remainingOldLines === 0 && remainingNewLines === 0) inHunk = false;
152
+ }
153
+ }
154
+ finish();
155
+ return sides;
156
+ }
157
+
158
+ function isUnifiedFileHeader(lines: readonly string[], index: number): boolean {
159
+ const oldLine = lines[index];
160
+ const newLine = lines[index + 1];
161
+ return (
162
+ oldLine !== undefined && newLine !== undefined && isPathHeader(oldLine, "--- ") && isPathHeader(newLine, "+++ ")
163
+ );
164
+ }
165
+
166
+ function isPathHeader(line: string, prefix: "--- " | "+++ "): boolean {
167
+ return line.startsWith(prefix) && line.slice(prefix.length).trim().length > 0;
168
+ }
169
+
170
+ function isDevNullHeader(line: string, prefix: "--- " | "+++ "): boolean {
171
+ return line.startsWith(prefix) && line.slice(prefix.length).split("\t", 1)[0]?.trim() === "/dev/null";
172
+ }
173
+
174
+ function convertHunk(file: FileDiffMetadata, fileRef: string, hunkIndex: number): UnifiedDiffHunk {
175
+ const source = file.hunks[hunkIndex]!;
176
+ const ref = `${fileRef}:h${hunkIndex}`;
177
+ const lines: UnifiedDiffLine[] = [];
178
+ let oldLine = source.deletionStart;
179
+ let newLine = source.additionStart;
180
+ for (const content of source.hunkContent) {
181
+ if (content.type === "context") {
182
+ for (let index = 0; index < content.lines; index += 1) {
183
+ lines.push(
184
+ line(
185
+ ref,
186
+ lines.length,
187
+ "context",
188
+ file.deletionLines[content.deletionLineIndex + index] ?? "",
189
+ oldLine,
190
+ newLine,
191
+ ),
192
+ );
193
+ oldLine += 1;
194
+ newLine += 1;
195
+ }
196
+ continue;
197
+ }
198
+ for (let index = 0; index < content.deletions; index += 1) {
199
+ lines.push(
200
+ line(
201
+ ref,
202
+ lines.length,
203
+ "removed",
204
+ file.deletionLines[content.deletionLineIndex + index] ?? "",
205
+ oldLine,
206
+ undefined,
207
+ ),
208
+ );
209
+ oldLine += 1;
210
+ }
211
+ for (let index = 0; index < content.additions; index += 1) {
212
+ lines.push(
213
+ line(
214
+ ref,
215
+ lines.length,
216
+ "added",
217
+ file.additionLines[content.additionLineIndex + index] ?? "",
218
+ undefined,
219
+ newLine,
220
+ ),
221
+ );
222
+ newLine += 1;
223
+ }
224
+ }
225
+ if (source.noEOFCRDeletions || source.noEOFCRAdditions) {
226
+ lines.push(line(ref, lines.length, "metadata", "\", undefined, undefined));
227
+ }
228
+ return Object.freeze({
229
+ ref,
230
+ header: cleanLine(source.hunkSpecs ?? inferredHunkHeader(source)),
231
+ oldStart: source.deletionStart,
232
+ oldCount: source.deletionCount,
233
+ newStart: source.additionStart,
234
+ newCount: source.additionCount,
235
+ lines: Object.freeze(lines),
236
+ });
237
+ }
238
+
239
+ function line(
240
+ hunkRef: string,
241
+ index: number,
242
+ kind: UnifiedDiffLine["kind"],
243
+ text: string,
244
+ oldLine: number | undefined,
245
+ newLine: number | undefined,
246
+ ): UnifiedDiffLine {
247
+ return Object.freeze({ ref: `${hunkRef}:l${index}`, kind, text: cleanLine(text), oldLine, newLine });
248
+ }
249
+
250
+ function inferredHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
251
+ return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@${hunk.hunkContext ? ` ${hunk.hunkContext}` : ""}`;
252
+ }
253
+
254
+ function cleanLine(text: string): string {
255
+ return sanitizeTuiText(text.replace(/\r?\n$/u, ""));
256
+ }
257
+
258
+ function normalizePath(raw: string | undefined): string | undefined {
259
+ if (!raw || raw === "/dev/null") return undefined;
260
+ const path = raw.split("\t", 1)[0]?.trim();
261
+ if (!path) return undefined;
262
+ return path.startsWith("a/") || path.startsWith("b/") ? path.slice(2) : path;
263
+ }
264
+
265
+ function boundSource(
266
+ source: string,
267
+ maxCharacters: number,
268
+ maxRows: number,
269
+ ): {
270
+ readonly source: string;
271
+ readonly rows: number;
272
+ readonly truncated: boolean;
273
+ } {
274
+ const characterBounded = source.slice(0, maxCharacters);
275
+ let end = characterBounded.length;
276
+ let rows = 0;
277
+ let cursor = 0;
278
+ while (cursor < characterBounded.length && rows < maxRows) {
279
+ const newline = characterBounded.indexOf("\n", cursor);
280
+ rows += 1;
281
+ if (newline < 0) {
282
+ cursor = characterBounded.length;
283
+ break;
284
+ }
285
+ cursor = newline + 1;
286
+ }
287
+ if (cursor < characterBounded.length) end = cursor;
288
+ const bounded = characterBounded.slice(0, end);
289
+ return { source: bounded, rows: Math.min(rows, maxRows), truncated: bounded.length < source.length };
290
+ }
291
+
292
+ function positiveInteger(value: number | undefined, fallback: number): number {
293
+ return value === undefined || !Number.isFinite(value) ? fallback : Math.max(1, Math.floor(value));
294
+ }
295
+
296
+ function touch<K, V>(cache: Map<K, V>, key: K, value: V): V {
297
+ cache.delete(key);
298
+ cache.set(key, value);
299
+ return value;
300
+ }
301
+
302
+ function boundedSet<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
303
+ cache.set(key, value);
304
+ while (cache.size > limit) {
305
+ const oldest = cache.keys().next().value as K | undefined;
306
+ if (oldest === undefined) break;
307
+ cache.delete(oldest);
308
+ }
309
+ return value;
310
+ }
@@ -0,0 +1,86 @@
1
+ import { renderDiffWithHighlighter, type ThemedDiffResult } from "@pierre/diffs";
2
+ import {
3
+ loadedDiffHighlighter,
4
+ type SyntaxHighlightSpan,
5
+ syntaxLanguage,
6
+ syntaxSpansFromPierreLine,
7
+ } from "../syntax.ts";
8
+ import type { UnifiedDiffModel } from "./model.ts";
9
+ import { pierreFilesFor } from "./parse.ts";
10
+
11
+ /** Syntax spans mapped to stable unified-diff line refs. */
12
+ export interface DiffSyntax {
13
+ readonly highlighted: boolean;
14
+ readonly lines: ReadonlyMap<string, readonly SyntaxHighlightSpan[]>;
15
+ }
16
+
17
+ const syntaxCache = new WeakMap<UnifiedDiffModel, ReadonlyMap<string, readonly SyntaxHighlightSpan[]>>();
18
+
19
+ /** Adapt Pierre's highlighted line nodes to the libtui diff model. */
20
+ export function diffSyntax(model: UnifiedDiffModel): DiffSyntax {
21
+ const highlighter = loadedDiffHighlighter();
22
+ const sources = pierreFilesFor(model);
23
+ if (!highlighter || sources.length === 0) return { highlighted: false, lines: new Map() };
24
+ const cached = syntaxCache.get(model);
25
+ if (cached) return { highlighted: true, lines: cached };
26
+ const lines = new Map<string, readonly SyntaxHighlightSpan[]>();
27
+ for (const [fileIndex, file] of model.files.entries()) {
28
+ const source = sources[fileIndex];
29
+ if (!source) continue;
30
+ let result: ThemedDiffResult;
31
+ try {
32
+ result = renderDiffWithHighlighter(
33
+ { ...source, lang: syntaxLanguage(file.newPath ?? file.oldPath) },
34
+ highlighter,
35
+ {
36
+ theme: "github-dark",
37
+ useTokenTransformer: true,
38
+ tokenizeMaxLineLength: 20_000,
39
+ lineDiffType: "word-alt",
40
+ maxLineDiffLength: 20_000,
41
+ },
42
+ );
43
+ } catch {
44
+ continue;
45
+ }
46
+ const deletionNodes = lineNodesByNumber(result.code.deletionLines);
47
+ const additionNodes = lineNodesByNumber(result.code.additionLines);
48
+ for (const hunk of file.hunks) {
49
+ for (const line of hunk.lines) {
50
+ const sourceNode =
51
+ line.kind === "removed"
52
+ ? takeLineNode(deletionNodes, line.oldLine)
53
+ : line.kind === "added" || line.kind === "context"
54
+ ? takeLineNode(additionNodes, line.newLine)
55
+ : undefined;
56
+ if (sourceNode) lines.set(line.ref, syntaxSpansFromPierreLine(sourceNode));
57
+ }
58
+ }
59
+ }
60
+ syntaxCache.set(model, lines);
61
+ return { highlighted: true, lines };
62
+ }
63
+
64
+ type PierreLineNode = ThemedDiffResult["code"]["additionLines"][number];
65
+
66
+ function lineNodesByNumber(nodes: readonly PierreLineNode[]): Map<number, PierreLineNode[]> {
67
+ const indexed = new Map<number, PierreLineNode[]>();
68
+ for (const node of nodes) {
69
+ if (node?.type !== "element") continue;
70
+ const raw = node.properties["data-line"];
71
+ const lineNumber = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN;
72
+ if (!Number.isFinite(lineNumber)) continue;
73
+ const bucket = indexed.get(lineNumber) ?? [];
74
+ bucket.push(node);
75
+ indexed.set(lineNumber, bucket);
76
+ }
77
+ return indexed;
78
+ }
79
+
80
+ function takeLineNode(
81
+ lines: Map<number, PierreLineNode[]>,
82
+ lineNumber: number | undefined,
83
+ ): PierreLineNode | undefined {
84
+ if (lineNumber === undefined) return undefined;
85
+ return lines.get(lineNumber)?.shift();
86
+ }