@orz/ui-ansi 0.0.0-stage → 0.0.0

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 (100) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +232 -2
  3. package/dist/content.d.ts +20 -0
  4. package/dist/content.d.ts.map +1 -0
  5. package/dist/content.js +90 -0
  6. package/dist/content.js.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/parse/index.d.ts +6 -0
  12. package/dist/parse/index.d.ts.map +1 -0
  13. package/dist/parse/index.js +5 -0
  14. package/dist/parse/index.js.map +1 -0
  15. package/dist/parse/level-tracker.d.ts +14 -0
  16. package/dist/parse/level-tracker.d.ts.map +1 -0
  17. package/dist/parse/level-tracker.js +87 -0
  18. package/dist/parse/level-tracker.js.map +1 -0
  19. package/dist/parse/levels.d.ts +19 -0
  20. package/dist/parse/levels.d.ts.map +1 -0
  21. package/dist/parse/levels.js +167 -0
  22. package/dist/parse/levels.js.map +1 -0
  23. package/dist/parse/line.d.ts +49 -0
  24. package/dist/parse/line.d.ts.map +1 -0
  25. package/dist/parse/line.js +289 -0
  26. package/dist/parse/line.js.map +1 -0
  27. package/dist/parse/palette.d.ts +12 -0
  28. package/dist/parse/palette.d.ts.map +1 -0
  29. package/dist/parse/palette.js +40 -0
  30. package/dist/parse/palette.js.map +1 -0
  31. package/dist/parse/parser.d.ts +18 -0
  32. package/dist/parse/parser.d.ts.map +1 -0
  33. package/dist/parse/parser.js +178 -0
  34. package/dist/parse/parser.js.map +1 -0
  35. package/dist/parse/sgr.d.ts +7 -0
  36. package/dist/parse/sgr.d.ts.map +1 -0
  37. package/dist/parse/sgr.js +179 -0
  38. package/dist/parse/sgr.js.map +1 -0
  39. package/dist/parse/styles.d.ts +15 -0
  40. package/dist/parse/styles.d.ts.map +1 -0
  41. package/dist/parse/styles.js +75 -0
  42. package/dist/parse/styles.js.map +1 -0
  43. package/dist/parse/tokenizer.d.ts +36 -0
  44. package/dist/parse/tokenizer.d.ts.map +1 -0
  45. package/dist/parse/tokenizer.js +287 -0
  46. package/dist/parse/tokenizer.js.map +1 -0
  47. package/dist/parse/types.d.ts +50 -0
  48. package/dist/parse/types.d.ts.map +1 -0
  49. package/dist/parse/types.js +2 -0
  50. package/dist/parse/types.js.map +1 -0
  51. package/dist/view/ansi-view.d.ts +27 -0
  52. package/dist/view/ansi-view.d.ts.map +1 -0
  53. package/dist/view/ansi-view.js +675 -0
  54. package/dist/view/ansi-view.js.map +1 -0
  55. package/dist/view/geometry.d.ts +30 -0
  56. package/dist/view/geometry.d.ts.map +1 -0
  57. package/dist/view/geometry.js +125 -0
  58. package/dist/view/geometry.js.map +1 -0
  59. package/dist/view/metrics.d.ts +17 -0
  60. package/dist/view/metrics.d.ts.map +1 -0
  61. package/dist/view/metrics.js +69 -0
  62. package/dist/view/metrics.js.map +1 -0
  63. package/dist/view/row.d.ts +41 -0
  64. package/dist/view/row.d.ts.map +1 -0
  65. package/dist/view/row.js +113 -0
  66. package/dist/view/row.js.map +1 -0
  67. package/dist/view/run-style.d.ts +11 -0
  68. package/dist/view/run-style.d.ts.map +1 -0
  69. package/dist/view/run-style.js +87 -0
  70. package/dist/view/run-style.js.map +1 -0
  71. package/dist/view/search.d.ts +28 -0
  72. package/dist/view/search.d.ts.map +1 -0
  73. package/dist/view/search.js +76 -0
  74. package/dist/view/search.js.map +1 -0
  75. package/dist/view/shown-lines.d.ts +15 -0
  76. package/dist/view/shown-lines.d.ts.map +1 -0
  77. package/dist/view/shown-lines.js +50 -0
  78. package/dist/view/shown-lines.js.map +1 -0
  79. package/dist/view/time-slices.d.ts +9 -0
  80. package/dist/view/time-slices.d.ts.map +1 -0
  81. package/dist/view/time-slices.js +35 -0
  82. package/dist/view/time-slices.js.map +1 -0
  83. package/dist/view/toolbar.d.ts +47 -0
  84. package/dist/view/toolbar.d.ts.map +1 -0
  85. package/dist/view/toolbar.js +58 -0
  86. package/dist/view/toolbar.js.map +1 -0
  87. package/dist/view/use-ansi-lines.d.ts +28 -0
  88. package/dist/view/use-ansi-lines.d.ts.map +1 -0
  89. package/dist/view/use-ansi-lines.js +103 -0
  90. package/dist/view/use-ansi-lines.js.map +1 -0
  91. package/dist/view/use-search.d.ts +37 -0
  92. package/dist/view/use-search.d.ts.map +1 -0
  93. package/dist/view/use-search.js +205 -0
  94. package/dist/view/use-search.js.map +1 -0
  95. package/dist/view/virtual-rows.d.ts +36 -0
  96. package/dist/view/virtual-rows.d.ts.map +1 -0
  97. package/dist/view/virtual-rows.js +156 -0
  98. package/dist/view/virtual-rows.js.map +1 -0
  99. package/package.json +58 -3
  100. package/styles.css +110 -0
@@ -0,0 +1,675 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "@orz/ui/components/button";
3
+ import { cellWidth as codePointCells } from "@orz/ui/lib/cell-width";
4
+ import { copyToClipboard } from "@orz/ui/lib/clipboard";
5
+ import { rowsInViewport } from "@orz/ui/lib/scroll-map";
6
+ import { cn } from "@orz/ui/lib/utils";
7
+ import { CopyIcon, DownloadIcon, WrapTextIcon } from "lucide-react";
8
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
9
+ import { detectLevel, LEVEL_INHERITED, } from "../parse/levels.js";
10
+ import { stripAnsi } from "../parse/parser.js";
11
+ import { buildRowIndex, columnsFor, lineAtRow, lineIdAt, splitRows, VIRTUALIZE_ABOVE, } from "./geometry.js";
12
+ import { useCellMetrics } from "./metrics.js";
13
+ import { AnsiRow, GUTTER_TEXT_GAP, gutterCells } from "./row.js";
14
+ import { createStyleCache } from "./run-style.js";
15
+ import { appendShownLines, buildShownLines, levelMask } from "./shown-lines.js";
16
+ import { LevelChips, SearchBox } from "./toolbar.js";
17
+ import { useAnsiLines } from "./use-ansi-lines.js";
18
+ import { useSearch } from "./use-search.js";
19
+ import { VirtualRows } from "./virtual-rows.js";
20
+ /** A virtualised view needs a bounded height even when none was given. */
21
+ const VIRTUAL_HEIGHT = "32rem";
22
+ const CONTENT_PADDING = 8;
23
+ /**
24
+ * The line between the gutter and the text, as a background of the rows'
25
+ * container: it spans the padding too, and adds no node a selection could
26
+ * land in.
27
+ */
28
+ function gutterRule(gutterCells) {
29
+ if (gutterCells === 0)
30
+ return undefined;
31
+ return {
32
+ backgroundImage: "linear-gradient(var(--color-border), var(--color-border))",
33
+ backgroundRepeat: "no-repeat",
34
+ backgroundSize: "1px 100%",
35
+ backgroundPosition: `calc(${gutterCells - GUTTER_TEXT_GAP}ch - 1px) 0`,
36
+ };
37
+ }
38
+ function lineCountLabel(count) {
39
+ return `${count.toLocaleString("en-US")} ${count === 1 ? "line" : "lines"}`;
40
+ }
41
+ /** The first position in `shown` holding line `id` or a later one; `id` itself with every line shown. */
42
+ function positionOf(shown, id) {
43
+ if (!shown)
44
+ return id;
45
+ let lo = 0;
46
+ let hi = shown.length;
47
+ while (lo < hi) {
48
+ const mid = (lo + hi) >> 1;
49
+ if (shown[mid] < id)
50
+ lo = mid + 1;
51
+ else
52
+ hi = mid;
53
+ }
54
+ return lo;
55
+ }
56
+ /** The first of `count` match triples on line `id` or a later one. */
57
+ function firstMatchFrom(matches, count, id) {
58
+ let lo = 0;
59
+ let hi = count;
60
+ while (lo < hi) {
61
+ const mid = (lo + hi) >> 1;
62
+ if (matches[3 * mid] < id)
63
+ lo = mid + 1;
64
+ else
65
+ hi = mid;
66
+ }
67
+ return lo;
68
+ }
69
+ /** Cells taken by `text` before UTF-16 offset `to`. */
70
+ function cellsBefore(text, to) {
71
+ let cells = 0;
72
+ for (let i = 0; i < to;) {
73
+ const cp = text.codePointAt(i);
74
+ cells += codePointCells(cp);
75
+ i += cp > 0xffff ? 2 : 1;
76
+ }
77
+ return cells;
78
+ }
79
+ /**
80
+ * Each shown list made by appending to another, mapped to that one: their
81
+ * common positions hold the same lines, so a row index over the older one
82
+ * can be extended.
83
+ */
84
+ const grownFrom = new WeakMap();
85
+ function grow(prev, next) {
86
+ if (prev && next && next !== prev)
87
+ grownFrom.set(next, prev);
88
+ return next;
89
+ }
90
+ function extendsShown(prev, next) {
91
+ return prev === next || (next !== null && grownFrom.get(next) === prev);
92
+ }
93
+ const NO_LEVELS = new Set();
94
+ /**
95
+ * The UTF-16 offset of the DOM point (`node`, `offset`) within the text of
96
+ * `root`, or null when the point is not inside `root`.
97
+ */
98
+ function textOffset(root, node, offset) {
99
+ let total = 0;
100
+ const visit = (n) => {
101
+ if (n === node) {
102
+ if (n.nodeType === Node.TEXT_NODE) {
103
+ total += offset;
104
+ }
105
+ else {
106
+ const children = n.childNodes;
107
+ for (let i = 0; i < offset && i < children.length; i++) {
108
+ total += children[i].textContent?.length ?? 0;
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+ if (n.nodeType === Node.TEXT_NODE) {
114
+ total += n.length;
115
+ return false;
116
+ }
117
+ for (const child of n.childNodes)
118
+ if (visit(child))
119
+ return true;
120
+ return false;
121
+ };
122
+ return visit(root) ? total : null;
123
+ }
124
+ const isBefore = (a, b) => a.line < b.line || (a.line === b.line && a.at < b.at);
125
+ /** Where a DOM point inside `row` falls in the model. */
126
+ function rowPoint(row, node, offset) {
127
+ const line = Number(row.getAttribute("data-line"));
128
+ const from = Number(row.getAttribute("data-from"));
129
+ const text = row.querySelector("[data-text]");
130
+ if (!text || Number.isNaN(line) || Number.isNaN(from))
131
+ return null;
132
+ let within = textOffset(text, node, offset);
133
+ if (within === null) {
134
+ // On the row itself or in its gutter: before or after the text.
135
+ const textIndex = Array.prototype.indexOf.call(row.childNodes, text);
136
+ within =
137
+ node === row && offset > textIndex ? (text.textContent?.length ?? 0) : 0;
138
+ }
139
+ return { line, at: from + within };
140
+ }
141
+ /**
142
+ * Where a selection endpoint falls in the model, or null when it is outside
143
+ * `view`. `kept` is where the same endpoint was last seen.
144
+ */
145
+ function selectionPoint(view, node, offset, kept) {
146
+ // Browsers move an endpoint whose row unmounts to the row's parent, but it
147
+ // may still be caught detached.
148
+ if (!node.isConnected)
149
+ return kept;
150
+ if (!view.contains(node))
151
+ return null;
152
+ const el = node instanceof Element ? node : node.parentElement;
153
+ const row = el?.closest("[data-line]");
154
+ if (row && view.contains(row))
155
+ return rowPoint(row, node, offset);
156
+ // Between rows, e.g. a drag past the last one: the start of the next row,
157
+ // or the end of the last.
158
+ const rows = view.querySelectorAll("[data-line]");
159
+ if (rows.length === 0)
160
+ return null;
161
+ const probe = document.createRange();
162
+ probe.setStart(node, offset);
163
+ let lo = 0;
164
+ let hi = rows.length;
165
+ while (lo < hi) {
166
+ const mid = (lo + hi) >> 1;
167
+ if (probe.comparePoint(rows[mid], 0) > 0)
168
+ hi = mid;
169
+ else
170
+ lo = mid + 1;
171
+ }
172
+ if (lo < rows.length) {
173
+ const next = rows[lo];
174
+ const point = rowPoint(next, next, 0);
175
+ // Before the first row of a virtual window, the endpoint stands in for a
176
+ // row scrolled out above it, where it was last seen.
177
+ if (point && lo === 0 && kept && isBefore(kept, point))
178
+ return kept;
179
+ return point;
180
+ }
181
+ const last = rows[rows.length - 1];
182
+ const point = rowPoint(last, last, last.childNodes.length);
183
+ if (point && kept && isBefore(point, kept))
184
+ return kept;
185
+ return point;
186
+ }
187
+ function rangesOf(line, cols) {
188
+ return cols === null || line.cells <= cols
189
+ ? [[0, line.text.length, 0]]
190
+ : splitRows(line, cols);
191
+ }
192
+ /**
193
+ * The row of layout `to` that holds the text at `row` of layout `from`. A line
194
+ * the filter now hides gives way to the next shown line.
195
+ */
196
+ function carryRow(row, from, to) {
197
+ if (from.lines !== to.lines)
198
+ return row;
199
+ if (from.cols === to.cols && from.shown === to.shown)
200
+ return row;
201
+ const pos = lineAtRow(from.index, row);
202
+ if (pos >= from.index.length - 1)
203
+ return row;
204
+ const id = lineIdAt(from.shown, pos);
205
+ const toPos = from.shown === to.shown ? pos : positionOf(to.shown, id);
206
+ const end = to.index.length - 1;
207
+ if (toPos >= end)
208
+ return to.index[end];
209
+ if (lineIdAt(to.shown, toPos) !== id)
210
+ return to.index[toPos];
211
+ const within = row - from.index[pos];
212
+ if (from.cols === to.cols)
213
+ return to.index[toPos] + within;
214
+ const line = to.lines[id];
215
+ const at = rangesOf(line, from.cols)[within]?.[0] ?? 0;
216
+ const ranges = rangesOf(line, to.cols);
217
+ let k = 0;
218
+ while (k + 1 < ranges.length && ranges[k + 1][0] <= at)
219
+ k++;
220
+ return to.index[toPos] + k;
221
+ }
222
+ /** Text with ANSI escapes drawn in the theme's terminal colours. */
223
+ export function AnsiView({ text, chrome, filename, downloadUrl, boldIsBright = false, wrap: initialWrap = false, maxHeight, levels = false, className, }) {
224
+ const detector = levels === true ? detectLevel : levels || false;
225
+ const parse = useAnsiLines(text, detector);
226
+ const { lines, styles, maxCells, done } = parse;
227
+ const levelCodes = parse.levels;
228
+ const [wrap, setWrap] = useState(initialWrap);
229
+ const scrollRef = useRef(null);
230
+ const searchRef = useRef(null);
231
+ const virtualRef = useRef(null);
232
+ const { rowHeight, cellWidth, width, viewportHeight } = useCellMetrics(scrollRef);
233
+ // Fences are short and keep the browser's find-in-page.
234
+ const searchable = chrome === "file";
235
+ const [hidden, setHidden] = useState(NO_LEVELS);
236
+ const [query, setQuery] = useState("");
237
+ const [caseSensitive, setCaseSensitive] = useState(false);
238
+ const [regex, setRegex] = useState(false);
239
+ const [onlyMatching, setOnlyMatching] = useState(false);
240
+ // A pane has no chips to undo a filter set before a switch to it.
241
+ const mask = levelMask(levelCodes && searchable ? hidden : NO_LEVELS);
242
+ // `lines` grows in place while a large text streams in; the shown lists
243
+ // and the row index extend the last ones rather than start over.
244
+ const levelShownRef = useRef(null);
245
+ const levelShown = useMemo(() => {
246
+ const upTo = levelCodes ? levelCodes.length : lines.length;
247
+ const last = levelShownRef.current;
248
+ const ids = last && last.lines === lines && last.mask === mask && last.upTo <= upTo
249
+ ? last.upTo === upTo
250
+ ? last.ids
251
+ : grow(last.ids, appendShownLines(last.ids, last.upTo, upTo, levelCodes, mask, null))
252
+ : buildShownLines(upTo, levelCodes, mask, null);
253
+ levelShownRef.current = { lines, mask, upTo, matchLines: null, ids };
254
+ return ids;
255
+ }, [lines, levelCodes, mask]);
256
+ // "Only matching lines" filters after the search, which reads the level
257
+ // filter's list: the search must not depend on its own results.
258
+ const search = useSearch({
259
+ query: searchable ? query : "",
260
+ caseSensitive,
261
+ regex,
262
+ lines,
263
+ count: lines.length,
264
+ done,
265
+ shown: levelShown,
266
+ });
267
+ const matchFilter = searchable && onlyMatching && query !== "" && search.error === null;
268
+ const matchShownRef = useRef(null);
269
+ const shown = useMemo(() => {
270
+ if (!matchFilter)
271
+ return levelShown;
272
+ const { matches, count, matchLines } = search;
273
+ // A scan walks the lines in order, so no match will come before its last.
274
+ const upTo = count > 0 ? matches[3 * (count - 1)] + 1 : 0;
275
+ const last = matchShownRef.current;
276
+ const ids = last &&
277
+ last.lines === lines &&
278
+ last.mask === mask &&
279
+ last.matchLines === matchLines &&
280
+ last.upTo <= upTo
281
+ ? last.upTo === upTo
282
+ ? last.ids
283
+ : grow(last.ids, appendShownLines(last.ids, last.upTo, upTo, levelCodes, mask, matchLines))
284
+ : appendShownLines(null, 0, upTo, levelCodes, mask, matchLines);
285
+ matchShownRef.current = { lines, mask, upTo, matchLines, ids };
286
+ return ids;
287
+ }, [matchFilter, levelShown, search, lines, levelCodes, mask]);
288
+ const gutterDigits = chrome === "file" ? String(Math.max(1, lines.length)).length : 0;
289
+ const gutterWidth = gutterCells(gutterDigits) * cellWidth;
290
+ const cols = wrap ? columnsFor(width - gutterWidth, cellWidth) : null;
291
+ const topRowNow = () => {
292
+ const handle = virtualRef.current;
293
+ if (handle)
294
+ return handle.topRow();
295
+ const el = scrollRef.current;
296
+ if (!el)
297
+ return 0;
298
+ return Math.max(0, Math.round((el.scrollTop - CONTENT_PADDING) / rowHeight));
299
+ };
300
+ const topRowNowRef = useRef(topRowNow);
301
+ topRowNowRef.current = topRowNow;
302
+ const lastLayout = useRef(null);
303
+ const layout = useMemo(() => {
304
+ const last = lastLayout.current;
305
+ const sameLines = last !== null && last.lines === parse.lines;
306
+ const extended = sameLines && extendsShown(last.shown, shown);
307
+ const prev = extended && last.cols === cols ? last.index : undefined;
308
+ const next = {
309
+ lines: parse.lines,
310
+ cols,
311
+ shown,
312
+ index: buildRowIndex(parse.lines, cols, prev, shown),
313
+ };
314
+ // Read before the new rows are committed, which may clamp scrollTop. A
315
+ // pending anchor wins: the rows drawn meanwhile did not reach it.
316
+ if (sameLines && !extended) {
317
+ next.anchor =
318
+ last.anchor ??
319
+ (last.index.length > 1
320
+ ? lineIdAt(last.shown, lineAtRow(last.index, topRowNowRef.current()))
321
+ : undefined);
322
+ }
323
+ else if (extended) {
324
+ // Still pending when a render was not committed, or StrictMode built
325
+ // this layout twice.
326
+ next.anchor = last.anchor;
327
+ }
328
+ lastLayout.current = next;
329
+ return next;
330
+ }, [parse, cols, shown]);
331
+ const { index } = layout;
332
+ const lineCount = index.length - 1;
333
+ const totalRows = index[lineCount];
334
+ // While a large text streams in, its rows so far undercount it; judged by
335
+ // them alone, the first slices would mount thousands of rows only to drop
336
+ // them a tick later.
337
+ // "Only matching lines" refills from nothing as a scan restarts: a list
338
+ // that was virtual stays so until the scan is done.
339
+ const wasVirtual = useRef(false);
340
+ const virtual = (parse.parsed > 0 ? totalRows / parse.parsed : totalRows) >
341
+ VIRTUALIZE_ABOVE ||
342
+ (wasVirtual.current && matchFilter && search.scanning);
343
+ wasVirtual.current = virtual;
344
+ const rowsOfLine = useMemo(() => {
345
+ const cache = new WeakMap();
346
+ return (line) => {
347
+ let rows = cache.get(line);
348
+ if (!rows) {
349
+ rows = rangesOf(line, cols);
350
+ if (rows.length > 1)
351
+ cache.set(line, rows);
352
+ }
353
+ return rows;
354
+ };
355
+ }, [cols]);
356
+ const styleCache = useMemo(() => createStyleCache(styles), [styles]);
357
+ const styleOf = useCallback((id) => styleCache(id, boldIsBright), [styleCache, boldIsBright]);
358
+ const levelMatchOf = useMemo(() => {
359
+ const cache = new WeakMap();
360
+ return (line, code) => {
361
+ // An inherited level has no word of its own on the line.
362
+ if (!detector || code === 0 || code & LEVEL_INHERITED)
363
+ return null;
364
+ let match = cache.get(line);
365
+ if (match === undefined) {
366
+ try {
367
+ match = detector(line.text);
368
+ }
369
+ catch {
370
+ // The level tracker has already reported it.
371
+ match = null;
372
+ }
373
+ cache.set(line, match);
374
+ }
375
+ return match;
376
+ };
377
+ }, [detector]);
378
+ const [nav, setNav] = useState(() => ({
379
+ index: -1,
380
+ generation: search.generation,
381
+ lines,
382
+ }));
383
+ if (nav.lines !== lines) {
384
+ setNav({ index: -1, generation: search.generation, lines });
385
+ }
386
+ const current = nav.lines === lines &&
387
+ nav.generation === search.generation &&
388
+ nav.index < search.count
389
+ ? nav.index
390
+ : -1;
391
+ const resetNav = () => setNav({ index: -1, generation: search.generation, lines });
392
+ // A row is memoised on its marks, so each line keeps its array until its
393
+ // matches or the current match change. Matches only append within one
394
+ // buffer; a new buffer is a new scan, or a grown one.
395
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new buffer starts a new cache
396
+ const markCache = useMemo(() => ({
397
+ byLine: new Map(),
398
+ current: -1,
399
+ }), [search.matches.buffer]);
400
+ if (markCache.current !== current) {
401
+ for (const k of [markCache.current, current]) {
402
+ if (k >= 0)
403
+ markCache.byLine.delete(search.matches[3 * k]);
404
+ }
405
+ markCache.current = current;
406
+ }
407
+ const marksOf = (id) => {
408
+ const { matches, count } = search;
409
+ if (count === 0)
410
+ return undefined;
411
+ const { byLine } = markCache;
412
+ if (byLine.has(id))
413
+ return byLine.get(id);
414
+ let marks;
415
+ for (let k = firstMatchFrom(matches, count, id); k < count && matches[3 * k] === id; k++) {
416
+ marks ??= [];
417
+ marks.push([matches[3 * k + 1], matches[3 * k + 2], k === current]);
418
+ }
419
+ // A scan finishes a line before it moves on, so lines up to the last
420
+ // match's are final; later ones may still be found.
421
+ if (id <= matches[3 * (count - 1)])
422
+ byLine.set(id, marks);
423
+ return marks;
424
+ };
425
+ const renderRow = (pos, range) => {
426
+ const id = lineIdAt(shown, pos);
427
+ const line = lines[id];
428
+ const [from, to] = range ?? [line.text.length, line.text.length];
429
+ const code = levelCodes ? levelCodes[id] : 0;
430
+ return (_jsx(AnsiRow, { lineIndex: id, line: line, from: from, to: to, styles: styles, styleOf: styleOf, gutterDigits: gutterDigits, level: code, levelMatch: levelMatchOf(line, code), marks: marksOf(id),
431
+ // The first shown line has no previous one to be apart from.
432
+ gap: shown !== null && pos > 0 && shown[pos - 1] + 1 !== id }, `${id}:${from}`));
433
+ };
434
+ const renderVirtualRow = (row) => {
435
+ const pos = lineAtRow(index, row);
436
+ const ranges = rowsOfLine(lines[lineIdAt(shown, pos)]);
437
+ return renderRow(pos, ranges[row - index[pos]]);
438
+ };
439
+ const contentWidth = wrap ? undefined : gutterWidth + maxCells * cellWidth;
440
+ let body;
441
+ if (virtual) {
442
+ body = (_jsx(VirtualRows, { scrollRef: scrollRef, totalRows: totalRows, rowHeight: rowHeight, viewportHeight: viewportHeight, width: contentWidth, renderRow: renderVirtualRow, layout: layout, carryRow: carryRow, padding: CONTENT_PADDING, style: gutterRule(gutterCells(gutterDigits)), handle: virtualRef }));
443
+ }
444
+ else {
445
+ const rows = [];
446
+ for (let pos = 0; pos < lineCount; pos++) {
447
+ for (const range of rowsOfLine(lines[lineIdAt(shown, pos)])) {
448
+ rows.push(renderRow(pos, range));
449
+ }
450
+ }
451
+ body = (_jsx("div", { className: "min-w-full", style: {
452
+ ...gutterRule(gutterCells(gutterDigits)),
453
+ width: contentWidth,
454
+ paddingBlock: CONTENT_PADDING,
455
+ }, children: rows }));
456
+ }
457
+ // A virtual view unmounts the rows a selection started in as it scrolls,
458
+ // so where each endpoint was last seen is kept.
459
+ const kept = useRef(null);
460
+ const linesRef = useRef(lines);
461
+ linesRef.current = lines;
462
+ const selectionPoints = useCallback((view, range) => {
463
+ const current = linesRef.current;
464
+ const last = kept.current?.lines === current ? kept.current : null;
465
+ return {
466
+ lines: current,
467
+ start: selectionPoint(view, range.startContainer, range.startOffset, last?.start ?? null),
468
+ end: selectionPoint(view, range.endContainer, range.endOffset, last?.end ?? null),
469
+ };
470
+ }, []);
471
+ useEffect(() => {
472
+ const onChange = () => {
473
+ const view = scrollRef.current;
474
+ const selection = document.getSelection();
475
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
476
+ kept.current =
477
+ view &&
478
+ range &&
479
+ (view.contains(range.startContainer) ||
480
+ view.contains(range.endContainer))
481
+ ? selectionPoints(view, range)
482
+ : null;
483
+ };
484
+ document.addEventListener("selectionchange", onChange);
485
+ return () => document.removeEventListener("selectionchange", onChange);
486
+ }, [selectionPoints]);
487
+ const onCopy = (event) => {
488
+ const selection = window.getSelection();
489
+ if (!selection || selection.rangeCount === 0)
490
+ return;
491
+ const range = selection.getRangeAt(0);
492
+ if (range.collapsed)
493
+ return;
494
+ const { start, end } = selectionPoints(event.currentTarget, range);
495
+ if (!start || !end)
496
+ return;
497
+ let copied;
498
+ if (start.line === end.line) {
499
+ copied = lines[start.line].text.slice(start.at, end.at);
500
+ }
501
+ else {
502
+ const parts = [lines[start.line].text.slice(start.at)];
503
+ // Lines the filter hides are not pasted.
504
+ for (let pos = positionOf(shown, start.line + 1); pos < lineCount; pos++) {
505
+ const id = lineIdAt(shown, pos);
506
+ if (id >= end.line)
507
+ break;
508
+ parts.push(lines[id].text);
509
+ }
510
+ parts.push(lines[end.line].text.slice(0, end.at));
511
+ copied = parts.join("\n");
512
+ }
513
+ event.clipboardData.setData("text/plain", copied);
514
+ event.preventDefault();
515
+ };
516
+ const copyAll = () => {
517
+ if (shown) {
518
+ const parts = new Array(shown.length);
519
+ for (let pos = 0; pos < shown.length; pos++) {
520
+ parts[pos] = lines[shown[pos]].text;
521
+ }
522
+ void copyToClipboard(parts.join("\n"), "Copied output");
523
+ return;
524
+ }
525
+ // Once parsed, the lines already are the stripped text; parsing a large
526
+ // log again would block for as long as the first parse took.
527
+ const plain = done
528
+ ? lines.map((line) => line.text).join("\n")
529
+ : stripAnsi(text);
530
+ void copyToClipboard(plain, "Copied output");
531
+ };
532
+ const scrollToRow = (row, align) => {
533
+ const handle = virtualRef.current;
534
+ if (handle) {
535
+ handle.scrollToRow(row, align);
536
+ return;
537
+ }
538
+ const el = scrollRef.current;
539
+ if (!el)
540
+ return;
541
+ const y = CONTENT_PADDING + row * rowHeight;
542
+ el.scrollTop =
543
+ align === "top"
544
+ ? row === 0
545
+ ? 0
546
+ : y
547
+ : Math.max(0, y - (viewportHeight - rowHeight) / 2);
548
+ };
549
+ const topLineNow = () => lineCount > 0 ? lineIdAt(shown, lineAtRow(index, topRowNow())) : 0;
550
+ /** Makes match `k` current and scrolls to it; `reveal` leaves a visible one where it is. */
551
+ const goTo = (k, reveal) => {
552
+ const { matches } = search;
553
+ setNav({ index: k, generation: search.generation, lines });
554
+ const id = matches[3 * k];
555
+ const pos = positionOf(shown, id);
556
+ const line = lines[id];
557
+ if (!line || pos >= lineCount || lineIdAt(shown, pos) !== id)
558
+ return;
559
+ const from = matches[3 * k + 1];
560
+ let row = index[pos];
561
+ if (cols !== null) {
562
+ const ranges = rowsOfLine(line);
563
+ let r = 0;
564
+ while (r + 1 < ranges.length && ranges[r + 1][0] <= from)
565
+ r++;
566
+ row += r;
567
+ }
568
+ const top = topRowNow();
569
+ if (!reveal ||
570
+ row < top ||
571
+ row >= top + rowsInViewport(viewportHeight, rowHeight)) {
572
+ scrollToRow(row, "center");
573
+ }
574
+ const el = scrollRef.current;
575
+ if (!el || cols !== null)
576
+ return;
577
+ const before = cellsBefore(line.text, from);
578
+ const start = gutterWidth + before * cellWidth;
579
+ const end = start + (cellsBefore(line.text, matches[3 * k + 2]) - before) * cellWidth;
580
+ if (start >= el.scrollLeft && end <= el.scrollLeft + width)
581
+ return;
582
+ el.scrollLeft = end <= width ? 0 : Math.max(0, (start + end - width) / 2);
583
+ };
584
+ const stepMatch = (back) => {
585
+ const { matches, count } = search;
586
+ if (count === 0)
587
+ return;
588
+ let k = current;
589
+ if (k < 0) {
590
+ k = firstMatchFrom(matches, count, topLineNow());
591
+ if (back)
592
+ k--;
593
+ }
594
+ else {
595
+ k += back ? -1 : 1;
596
+ }
597
+ goTo((k + count) % count, false);
598
+ };
599
+ // More lines may still join the shown list, past those it holds.
600
+ const growing = !done || (matchFilter && search.scanning);
601
+ // The filter changed: keep the line that was at the top, or the next one
602
+ // still shown. A list refilling from nothing waits until it reaches that
603
+ // line, or can grow no more.
604
+ useLayoutEffect(() => {
605
+ const { anchor, index: rows, shown: ids } = layout;
606
+ if (anchor === undefined)
607
+ return;
608
+ const end = rows.length - 1;
609
+ if (end === 0)
610
+ return;
611
+ if (growing && lineIdAt(ids, end - 1) < anchor)
612
+ return;
613
+ layout.anchor = undefined;
614
+ const pos = Math.min(positionOf(ids, anchor), end - 1);
615
+ if (lineAtRow(rows, topRowNowRef.current()) === pos)
616
+ return;
617
+ scrollToRow(rows[pos], "top");
618
+ });
619
+ // A new search goes to its first match at or below the top line, so typing
620
+ // does not jump back to the start of the log.
621
+ useLayoutEffect(() => {
622
+ if (nav.generation === search.generation || search.count === 0)
623
+ return;
624
+ // A pending anchor is where the reader is; the scroll has yet to follow.
625
+ const k = firstMatchFrom(search.matches, search.count, layout.anchor ?? topLineNow());
626
+ // The scan has not reached the top line yet.
627
+ if (k === search.count && search.scanning)
628
+ return;
629
+ goTo(k < search.count ? k : 0, true);
630
+ });
631
+ const onRootKeyDown = (event) => {
632
+ if (!(event.ctrlKey || event.metaKey) || event.altKey)
633
+ return;
634
+ if (event.key.toLowerCase() !== "f")
635
+ return;
636
+ const input = searchRef.current;
637
+ if (!input)
638
+ return;
639
+ event.preventDefault();
640
+ input.focus();
641
+ input.select();
642
+ };
643
+ const tools = (_jsxs(_Fragment, { children: [_jsx(Button, { size: "xs", variant: "ghost", "aria-label": "Wrap lines", title: "Wrap lines", "aria-pressed": wrap, className: "aria-pressed:bg-muted aria-pressed:text-foreground", onClick: () => setWrap((on) => !on), children: _jsx(WrapTextIcon, {}) }), _jsx(Button, { size: "xs", variant: "ghost", "aria-label": "Copy output", title: "Copy output", onClick: copyAll, children: _jsx(CopyIcon, {}) })] }));
644
+ const scroller = (_jsx("section", { ref: scrollRef, "aria-label": filename ?? "Terminal output",
645
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrolling region takes focus so the keyboard can scroll it
646
+ tabIndex: 0, onCopy: onCopy, className: "relative min-h-0 flex-auto overflow-auto px-3 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", style: virtual
647
+ ? { height: maxHeight ?? VIRTUAL_HEIGHT }
648
+ : maxHeight === undefined
649
+ ? undefined
650
+ : { maxHeight }, children: body }));
651
+ const rootStyle = {
652
+ "--ansi-row-height": `${rowHeight}px`,
653
+ };
654
+ if (chrome === "pane") {
655
+ return (_jsxs("div", { className: cn("ansi-view group relative flex flex-col overflow-hidden rounded-lg border border-border bg-terminal-background font-mono text-terminal-foreground", className), style: rootStyle, children: [_jsx("div", { className: "absolute top-1 right-1 z-10 flex gap-0.5 rounded-md bg-terminal-background opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100", children: tools }), scroller] }));
656
+ }
657
+ const count = text === "" ? 0 : lines.length;
658
+ const countLabel = shown
659
+ ? `${shown.length.toLocaleString("en-US")} of ${lineCountLabel(count)}`
660
+ : lineCountLabel(count);
661
+ return (_jsxs("div", { className: cn("ansi-view flex flex-col overflow-hidden rounded-lg border border-border bg-terminal-background font-mono text-terminal-foreground", className), style: rootStyle, onKeyDown: onRootKeyDown, children: [_jsxs("div", { className: "shrink-0 border-b border-border bg-card font-sans text-card-foreground text-xs", children: [_jsxs("div", { className: "flex items-center gap-2 py-1 pr-1 pl-3", children: [filename !== undefined && (_jsx("span", { className: "min-w-0 truncate font-medium", children: filename })), _jsx("span", { className: "shrink-0 text-muted-foreground", children: done ? countLabel : `Parsing… ${countLabel}` }), _jsxs("div", { className: "ml-auto flex shrink-0 gap-0.5", children: [tools, downloadUrl !== undefined && (_jsx(Button, { asChild: true, size: "xs", variant: "ghost", children: _jsx("a", { href: downloadUrl, download: filename ?? "", "aria-label": "Download", title: "Download", children: _jsx(DownloadIcon, {}) }) }))] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border border-dashed py-1 pr-1 pl-3", children: [parse.levelCounts && text !== "" && (_jsx(LevelChips, { counts: parse.levelCounts, hidden: hidden, onChange: (next) => {
662
+ setHidden(next);
663
+ resetNav();
664
+ } })), _jsx(SearchBox, { className: "ml-auto", inputRef: searchRef, query: query, onQuery: (next) => {
665
+ setQuery(next);
666
+ resetNav();
667
+ }, counter: { ...search, current }, caseSensitive: caseSensitive, onCaseSensitive: (on) => {
668
+ setCaseSensitive(on);
669
+ resetNav();
670
+ }, regex: regex, onRegex: (on) => {
671
+ setRegex(on);
672
+ resetNav();
673
+ }, onlyMatching: onlyMatching, onOnlyMatching: setOnlyMatching, onStep: stepMatch, onLeave: () => scrollRef.current?.focus() })] })] }), scroller] }));
674
+ }
675
+ //# sourceMappingURL=ansi-view.js.map