@develemit/web-hooks 0.3.1 → 0.5.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.
- package/CHANGELOG.md +16 -0
- package/README.md +115 -0
- package/dist/chunk-IVLRQ23V.js +483 -0
- package/dist/chunk-JTLQQSLO.js +143 -0
- package/dist/index.cjs +639 -7
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +44 -0
- package/dist/log-helpers-rkbgYqZW.d.cts +25 -0
- package/dist/log-helpers-rkbgYqZW.d.ts +25 -0
- package/dist/terminal-output.cjs +660 -0
- package/dist/terminal-output.d.cts +163 -0
- package/dist/terminal-output.d.ts +163 -0
- package/dist/terminal-output.js +47 -0
- package/dist/virtual-list.cjs +499 -0
- package/dist/virtual-list.d.cts +116 -0
- package/dist/virtual-list.d.ts +116 -0
- package/dist/virtual-list.js +287 -0
- package/package.json +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- **New optional peer dependency:** `@tanstack/react-virtual` (`^3.13.0`), needed only by apps that import `@develemit/web-hooks/virtual-list`. Every other entry, including the root, works without it.
|
|
6
|
+
- `virtual-list`: `useVirtualLog({ lines, scrollRef, query, estimateSize, overscan, resetKey, filename })` combines `useVirtualList` with `useFollowTail`, a deferred `filterLines` search, incremental `findFailures` with `jumpToFailure` / `nextFailure` / `previousFailure` by index, and `copyText` / `download` over the whole log. Also exports `logToText` and `downloadText`. Budgets measured in WebKit on a 20,765-line, 1.4 MB log: first render about 20 ms with 40 rows in the page, filter keystrokes under 50 ms, about 3 ms of render per batch of appended lines.
|
|
7
|
+
- `virtual-list`: new subpath with `useVirtualList({ count, estimateSize, scrollRef | windowScroll, scrollMargin, overscan = 10, measure = true, getItemKey, initialCount = 30 })` returning `{ items, totalSize, measureRef, scrollToIndex, scrollToOffset, virtualizer }`, plus the pure `measuredOrEstimate`, `shouldRealign`, `targetOffset` and `initialVisibleCount`. Rows are measured by default (a 0 reading falls back to the estimate), it can scroll an element or the window, `scrollToIndex` re-aligns for up to 3 frames after measuring so it lands on unrendered rows, and the server render includes the first rows. `@tanstack/react-virtual` (`^3.13.0`) is an **optional** peer dependency used only by this subpath; the root index does not re-export it.
|
|
8
|
+
|
|
9
|
+
## 0.4.0 - 2026-11-06
|
|
10
|
+
|
|
11
|
+
- `terminal-output`: `useFollowTail` detaches on a user scroll up even when output streams every frame. It now detects intent (wheel up, a finger dragging down, a press on the scrollbar gutter, or a scroll event above where the hook last scrolled to) instead of ignoring scroll events in the frame after its own scroll. New pure `isUserScrollUp`.
|
|
12
|
+
- `terminal-output`: `useFollowTail({ scrollRef, itemCount, scrollToEnd, resetKey, threshold })` returning `{ atBottom, unread, jumpToBottom }`, plus the pure `shouldStick`, `isNearEnd`, `distanceFromEnd` and `unreadDelta`. Follows new output while at the bottom (24px threshold, so iOS rubber-band counts), detaches on a user scroll up without being fooled by its own scrolls, counts unread items, and works with a virtualizer through `scrollToEnd`.
|
|
13
|
+
- `terminal-output`: log helpers on plain lines. `filterLines` and `splitByMatch` (case-insensitive plain-text search and highlight), `findFailures` / `firstFailureIndex` (per-tool patterns for Vitest, Nx, tsc, pnpm, npm and Docker, returning `{ index, tool, kind }`), `isNoise` and `tailMeaningful`. `FAILURE_PATTERNS` and `NOISE_PATTERNS` are exported so apps can extend them.
|
|
14
|
+
- `terminal-output`: `createAnsiStream().finish(partial?)` ends a stream without adding a trailing newline (pushes after it are ignored until `reset()`). `push()` now re-renders only rows that were written to, instead of cloning and rendering the whole tail on every call.
|
|
15
|
+
- `terminal-output`: `createLineBuffer` (stream chunks or bytes to complete lines; holds the unfinished line and any half-written escape code, joins `\r\n` and UTF-8 split across chunks, caps memory with a `dropped` count, tracks the server's resume cursor) and `createAnsiStream` (renders those lines with a small screen tail, so streamed cursor-up redraws give the same rows as `parseAnsi` of the whole log).
|
|
16
|
+
- `terminal-output`: `stripAnsi`, `parseAnsi` and `createAnsiParser`. Styled segments with `\r`, cursor-up and erase-line redraws collapsed, OSC 8 links filtered to `http`/`https`/`file`, no HTML anywhere.
|
|
17
|
+
- `terminal-output`: new subpath. Holds the real terminal captures (`src/terminal-output/fixtures/`, test-only, not published) that the ANSI strip, parse, line-buffer and log helpers will be tested against.
|
|
18
|
+
|
|
3
19
|
## 0.3.1 - 2026-11-06
|
|
4
20
|
|
|
5
21
|
- `service-worker`: `useServiceWorker({ onError })` and `registerServiceWorker({ onError })`. Called with the rejection when `register()` fails (not after unmount or dispose). The latest callback is used; a throwing callback is swallowed. Registration failure still never throws into React.
|
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ that every app ends up hand-copying from the last one.
|
|
|
24
24
|
| `@develemit/web-hooks/share` | Native share sheet with a clipboard fallback that works over plain http |
|
|
25
25
|
| `@develemit/web-hooks/keyboard-inset` | On-screen keyboard height, without the phantom gap in standalone iPhone PWAs |
|
|
26
26
|
| `@develemit/web-hooks/push` | Web push subscription, with iOS install detection and silent re-subscribe |
|
|
27
|
+
| `@develemit/web-hooks/terminal-output` | Strip and parse ANSI, buffer streamed lines, search logs and find failures, and follow the tail of a scrolling log |
|
|
28
|
+
| `@develemit/web-hooks/virtual-list` | Virtualize long lists on `@tanstack/react-virtual` with phone-safe defaults (optional peer) |
|
|
27
29
|
|
|
28
30
|
Behavior only. No styling and no components: each app keeps its own look.
|
|
29
31
|
|
|
@@ -405,3 +407,116 @@ the dead subscription is unsubscribed and one fresh one is made and sent. The
|
|
|
405
407
|
package never makes HTTP requests: `onSubscribe(subscription:
|
|
406
408
|
PushSubscriptionJSON)` and `onUnsubscribe(endpoint)` are yours. The pure
|
|
407
409
|
`urlBase64ToUint8Array`, `getPushSupport` and `shouldResubscribe` are exported.
|
|
410
|
+
|
|
411
|
+
### terminal-output
|
|
412
|
+
|
|
413
|
+
`@develemit/web-hooks/terminal-output` turns raw terminal text into data. Log helpers (search, failures, noise) work on plain lines. Tests run against real captures in `src/terminal-output/fixtures/` (not published).
|
|
414
|
+
|
|
415
|
+
- `stripAnsi(text)` removes CSI (including `?` private modes), OSC (BEL or `ESC \` terminated) and two-byte escapes. It leaves `\r` and `\n` alone.
|
|
416
|
+
- `parseAnsi(text, { linkSchemes? })` returns `AnsiLine[]`, each line an array of `{ text, fg?, bg?, bold?, dim?, italic?, underline?, inverse?, href? }`. Colours are `{ kind: 'ansi', index }` (0-15, or 0-255 for 256-colour) or `{ kind: 'rgb', r, g, b }`; your app maps them to its palette. OSC 8 links become `href`, and only `http`, `https` and `file` are kept.
|
|
417
|
+
- It emulates what progress output needs: `\r`, cursor up/down (`A`/`B`), column set (`G`), erase-line (`K`) and erase-below (`0J`), so redraws collapse to their final state. Every other escape sequence is dropped; use xterm.js for full emulation.
|
|
418
|
+
- `createAnsiParser(options)` returns `{ parse(chunk), reset() }`. Colour and link state carry across calls; the screen does not, so give it whole lines.
|
|
419
|
+
|
|
420
|
+
- `createLineBuffer({ maxLines })` turns chunks (`string` or `Uint8Array`) into complete raw lines. `push(chunk, { cursor? })` returns `{ lines, partial, dropped }`: the new lines (escape codes intact), the unfinished line (without a half-written escape code, which waits for the next chunk), and how many old lines this push dropped. `\r\n` and multi-byte characters split across chunks are joined; a lone `\r` stays in the line for the parser. `flush()` ends the stream, `buffer.lines` holds what is kept, `buffer.dropped` the total dropped.
|
|
421
|
+
- **Resuming is the server's job.** Pass its cursor to `push`; after a dropped connection ask the server for `since: buffer.cursor` and push again. The buffer never compares text, so a log that repeats a line keeps both.
|
|
422
|
+
- `createAnsiStream({ tailLines = 50 })` renders those lines. `push(lines, partial)` returns `{ replaced?: { from, lines } }`: truncate your rendered rows to `from`, then append `lines`. Its screen tail lets a redraw reach rows already shown (pnpm's cursor-up progress, npm's spinner), so the result equals `parseAnsi(wholeLog)`. The tail grows to the largest cursor-up it sees; rows further back are frozen. `from` counts from the stream's first row, not from your capped list. When the stream ends, call `stream.finish()`: it renders the unfinished line you last pushed (or the `partial` you pass) without adding a newline, so the rows equal `parseAnsi(wholeLog)`. Don't push `lineBuffer.flush()` output into the stream. Pushes after `finish()` are ignored until `reset()`.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
const buffer = createLineBuffer({ maxLines: 10_000 });
|
|
426
|
+
const stream = createAnsiStream();
|
|
427
|
+
const rows: AnsiLine[] = [];
|
|
428
|
+
|
|
429
|
+
function onChunk(chunk: string | Uint8Array, cursor: number) {
|
|
430
|
+
const { lines, partial } = buffer.push(chunk, { cursor });
|
|
431
|
+
const { replaced } = stream.push(lines, partial);
|
|
432
|
+
if (replaced) rows.splice(replaced.from, rows.length, ...replaced.lines);
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Log helpers** take plain lines (run `stripAnsi` or join `parseAnsi` segments first):
|
|
437
|
+
|
|
438
|
+
- `filterLines(lines, query)` returns the matching indexes (every index for a blank query). `splitByMatch(line, query)` returns `{ text, match }[]` for highlighting. Both are case-insensitive plain text, never regex.
|
|
439
|
+
- `findFailures(lines, patterns?)` returns `{ index, tool, kind }[]` (tools: `vitest`, `nx`, `tsc`, `pnpm`, `npm`, `docker`, `generic`); `firstFailureIndex` returns the first index or `-1`. Nx matches both `targets failed, including` and the older `Failed tasks`. Stack-frame lines never count. Extend with `[...FAILURE_PATTERNS, { tool, kind, pattern }]`.
|
|
440
|
+
- `isNoise(line)` and `tailMeaningful(lines, n)` hide Docker pull and compose chatter and Nx cache lines; the tail is the last `n` non-blank lines that matter. Extend `NOISE_PATTERNS` the same way.
|
|
441
|
+
|
|
442
|
+
**Following live output.** `useFollowTail({ scrollRef, itemCount, scrollToEnd?, resetKey?, threshold = 24 })` returns `{ atBottom, unread, jumpToBottom }`. While the user is within `threshold` px of the end (rubber-band overshoot counts), new items scroll to the end in a layout effect. A user scroll up detaches; items added after that are counted in `unread`; scrolling back down or calling `jumpToBottom()` re-sticks and clears it. The hook's own scrolls never detach. Pass `scrollToEnd` to use a virtualizer (`() => virtualizer.scrollToIndex(count - 1, { align: 'end' })`), and `resetKey` to snap back when the content changes (a project switch). The pure `shouldStick`, `isNearEnd`, `distanceFromEnd` and `unreadDelta` are exported.
|
|
443
|
+
|
|
444
|
+
Segments are plain text, so there is no HTML to escape:
|
|
445
|
+
|
|
446
|
+
```tsx
|
|
447
|
+
import { parseAnsi } from '@develemit/web-hooks/terminal-output';
|
|
448
|
+
|
|
449
|
+
const COLORS = ['text-black', 'text-red', 'text-green', 'text-yellow', 'text-blue', 'text-magenta', 'text-cyan', 'text-white'];
|
|
450
|
+
|
|
451
|
+
{parseAnsi(log).map((line, i) => (
|
|
452
|
+
<div key={i}>
|
|
453
|
+
{line.map((seg, j) => (
|
|
454
|
+
<span key={j} className={`${seg.bold ? 'font-bold' : ''} ${seg.fg?.kind === 'ansi' ? COLORS[seg.fg.index % 8] : ''}`}>
|
|
455
|
+
{seg.text}
|
|
456
|
+
</span>
|
|
457
|
+
))}
|
|
458
|
+
</div>
|
|
459
|
+
))}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### virtual-list
|
|
463
|
+
|
|
464
|
+
`@develemit/web-hooks/virtual-list` wraps `@tanstack/react-virtual` (v3.13+). Install it yourself (`pnpm add @tanstack/react-virtual`); it is an **optional** peer, needed only by apps that import this subpath. The root `@develemit/web-hooks` import does not re-export it, so root imports never need the peer.
|
|
465
|
+
|
|
466
|
+
`useVirtualList({ count, estimateSize, scrollRef? | windowScroll: true, scrollMargin = 0, overscan = 10, measure = true, getItemKey?, initialCount = 30 })` returns `{ items, totalSize, measureRef, scrollToIndex(i, { align }), scrollToOffset, virtualizer }`; `items` are `{ index, key, start, size }`, and `virtualizer` is the raw escape hatch.
|
|
467
|
+
|
|
468
|
+
- Rows are measured by default; a row that reads 0 before layout keeps its estimate, so the list never collapses. Pass `measure: false` if your own maths relies on flat estimates.
|
|
469
|
+
- `windowScroll: true` scrolls with the page (use `scrollMargin` for the list's distance from the page top; item `start` is relative to the list).
|
|
470
|
+
- `scrollToIndex` to a row that isn't rendered yet scrolls to the estimate, then re-aligns after measuring, for up to 3 frames.
|
|
471
|
+
- The server render includes the first `initialCount` rows.
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
475
|
+
const { items, totalSize, measureRef } = useVirtualList({ count: lines.length, estimateSize: () => 20, scrollRef: ref });
|
|
476
|
+
|
|
477
|
+
<div ref={ref} className="h-96 overflow-auto">
|
|
478
|
+
<div style={{ height: totalSize, position: 'relative' }}>
|
|
479
|
+
{items.map((it) => (
|
|
480
|
+
<div key={it.key} data-index={it.index} ref={measureRef}
|
|
481
|
+
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${it.start}px)` }}>
|
|
482
|
+
{lines[it.index]}
|
|
483
|
+
</div>
|
|
484
|
+
))}
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Each row needs `data-index={it.index}` and `ref={measureRef}`.
|
|
490
|
+
|
|
491
|
+
#### useVirtualLog
|
|
492
|
+
|
|
493
|
+
`useVirtualLog({ lines, scrollRef, query = '', estimateSize = () => 18, overscan = 12, resetKey?, filename = 'log.txt' })` joins `useVirtualList` with the `terminal-output` helpers for a live, searchable log. Pass the lines as strings (strip or keep escape codes; search and failure scans run on what you pass, so give it plain text if you want to search plain text).
|
|
494
|
+
|
|
495
|
+
It returns `rows` (`{ index, lineIndex, key, start, size }`, where `lineIndex` is the row's place in `lines`), `totalSize`, `measureRef`, `isFiltering`, `shownCount`, `atBottom`, `unread`, `jumpToBottom`, `failures`, `failurePosition`, `jumpToFailure(n)`, `nextFailure()`, `previousFailure()`, `text()`, `copyText()` and `download(filename?)`.
|
|
496
|
+
|
|
497
|
+
- Search goes through `useDeferredValue`, so typing stays instant on 20k lines. Rows are the matches; map them back with `lines[row.lineIndex]` for highlighting with `splitByMatch`.
|
|
498
|
+
- It follows the tail and stops when the user scrolls up. While a filter is on, following pauses and `unread` counts matches that arrived since the filter began.
|
|
499
|
+
- `failures` is scanned once per content change, and only on appended rows. `jumpToFailure(n)` scrolls by index (so it works for rows that aren't rendered) and returns `false` if that failure is hidden by the filter.
|
|
500
|
+
- `copyText()` and `download()` use the whole log with escape codes stripped, however few rows are in the page. `logToText(lines, { stripAnsi })` and `downloadText(filename, text)` are exported on their own.
|
|
501
|
+
- `lines` is an array, not a stream: keep your own state (for example `createLineBuffer` + `createAnsiStream`) and pass its rows.
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
505
|
+
const [query, setQuery] = useState('');
|
|
506
|
+
const log = useVirtualLog({ lines, scrollRef: ref, query });
|
|
507
|
+
|
|
508
|
+
<div ref={ref} className="h-96 overflow-auto">
|
|
509
|
+
<div style={{ height: log.totalSize, position: 'relative' }}>
|
|
510
|
+
{log.rows.map((row) => (
|
|
511
|
+
<div key={row.key} data-index={row.index} ref={log.measureRef}
|
|
512
|
+
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${row.start}px)` }}>
|
|
513
|
+
{log.isFiltering
|
|
514
|
+
? splitByMatch(lines[row.lineIndex] ?? '', query).map((s, j) => (s.match ? <mark key={j}>{s.text}</mark> : <span key={j}>{s.text}</span>))
|
|
515
|
+
: lines[row.lineIndex]}
|
|
516
|
+
</div>
|
|
517
|
+
))}
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
{!log.atBottom && <button onClick={log.jumpToBottom}>{log.unread} new</button>}
|
|
521
|
+
<button onClick={log.nextFailure}>Next failure</button>
|
|
522
|
+
```
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/terminal-output/ansi-sgr.ts
|
|
4
|
+
var FLAG_ON = {
|
|
5
|
+
1: "bold",
|
|
6
|
+
2: "dim",
|
|
7
|
+
3: "italic",
|
|
8
|
+
4: "underline",
|
|
9
|
+
7: "inverse"
|
|
10
|
+
};
|
|
11
|
+
var FLAG_OFF = {
|
|
12
|
+
22: ["bold", "dim"],
|
|
13
|
+
23: ["italic"],
|
|
14
|
+
24: ["underline"],
|
|
15
|
+
27: ["inverse"]
|
|
16
|
+
};
|
|
17
|
+
function byte(n) {
|
|
18
|
+
return Math.max(0, Math.min(255, n));
|
|
19
|
+
}
|
|
20
|
+
function extendedColor(args) {
|
|
21
|
+
if (args[0] === 5 && args.length >= 2) return { kind: "ansi", index: byte(args[1] ?? 0) };
|
|
22
|
+
if (args[0] === 2 && args.length >= 4) {
|
|
23
|
+
const [r = 0, g = 0, b = 0] = args.slice(-3);
|
|
24
|
+
return { kind: "rgb", r: byte(r), g: byte(g), b: byte(b) };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function setKey(style, key, value) {
|
|
29
|
+
if (value === void 0 || value === false) delete style[key];
|
|
30
|
+
else style[key] = value;
|
|
31
|
+
}
|
|
32
|
+
function applySgr(style, params) {
|
|
33
|
+
const next = { ...style };
|
|
34
|
+
const tokens = params === "" ? ["0"] : params.split(";");
|
|
35
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
36
|
+
const parts = (tokens[i] ?? "").split(":").map((p) => p === "" ? 0 : Number(p));
|
|
37
|
+
const code = parts[0] ?? 0;
|
|
38
|
+
if (Number.isNaN(code)) continue;
|
|
39
|
+
if (code === 38 || code === 48) {
|
|
40
|
+
const key = code === 38 ? "fg" : "bg";
|
|
41
|
+
let args = parts.slice(1);
|
|
42
|
+
if (parts.length === 1) {
|
|
43
|
+
args = tokens.slice(i + 1, i + 5).map(Number);
|
|
44
|
+
const used = args[0] === 5 ? 2 : args[0] === 2 ? 4 : 0;
|
|
45
|
+
i += used;
|
|
46
|
+
args = args.slice(0, used);
|
|
47
|
+
}
|
|
48
|
+
const color = extendedColor(args);
|
|
49
|
+
if (color) setKey(next, key, color);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (code === 0) {
|
|
53
|
+
for (const k of Object.keys(next)) delete next[k];
|
|
54
|
+
} else if (FLAG_ON[code]) setKey(next, FLAG_ON[code], true);
|
|
55
|
+
else if (FLAG_OFF[code]) (FLAG_OFF[code] ?? []).forEach((k) => setKey(next, k, void 0));
|
|
56
|
+
else if (code >= 30 && code <= 37) setKey(next, "fg", { kind: "ansi", index: code - 30 });
|
|
57
|
+
else if (code === 39) setKey(next, "fg", void 0);
|
|
58
|
+
else if (code >= 40 && code <= 47) setKey(next, "bg", { kind: "ansi", index: code - 40 });
|
|
59
|
+
else if (code === 49) setKey(next, "bg", void 0);
|
|
60
|
+
else if (code >= 90 && code <= 97) setKey(next, "fg", { kind: "ansi", index: code - 90 + 8 });
|
|
61
|
+
else if (code >= 100 && code <= 107) setKey(next, "bg", { kind: "ansi", index: code - 100 + 8 });
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
function colorEquals(a, b) {
|
|
66
|
+
if (a === b) return true;
|
|
67
|
+
if (!a || !b || a.kind !== b.kind) return false;
|
|
68
|
+
if (a.kind === "ansi") return a.index === b.index;
|
|
69
|
+
const o = b;
|
|
70
|
+
return a.r === o.r && a.g === o.g && a.b === o.b;
|
|
71
|
+
}
|
|
72
|
+
function styleEquals(a, b) {
|
|
73
|
+
return a === b || colorEquals(a.fg, b.fg) && colorEquals(a.bg, b.bg) && !!a.bold === !!b.bold && !!a.dim === !!b.dim && !!a.italic === !!b.italic && !!a.underline === !!b.underline && !!a.inverse === !!b.inverse;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/terminal-output/ansi-screen.ts
|
|
77
|
+
var BLANK = {};
|
|
78
|
+
var newRow = () => ({ cells: [], shared: false });
|
|
79
|
+
var Screen = class _Screen {
|
|
80
|
+
rows = [newRow()];
|
|
81
|
+
row = 0;
|
|
82
|
+
col = 0;
|
|
83
|
+
line() {
|
|
84
|
+
const row = this.rows[this.row] ??= newRow();
|
|
85
|
+
if (row.shared) {
|
|
86
|
+
row.cells = row.cells.slice();
|
|
87
|
+
row.shared = false;
|
|
88
|
+
}
|
|
89
|
+
row.segments = void 0;
|
|
90
|
+
return row.cells;
|
|
91
|
+
}
|
|
92
|
+
write(ch, style, href) {
|
|
93
|
+
const line = this.line();
|
|
94
|
+
while (line.length < this.col) line.push({ ch: " ", style: BLANK });
|
|
95
|
+
line[this.col] = { ch, style, href };
|
|
96
|
+
this.col++;
|
|
97
|
+
}
|
|
98
|
+
newline() {
|
|
99
|
+
this.moveRow(this.row + 1);
|
|
100
|
+
this.col = 0;
|
|
101
|
+
}
|
|
102
|
+
carriageReturn() {
|
|
103
|
+
this.col = 0;
|
|
104
|
+
}
|
|
105
|
+
backspace() {
|
|
106
|
+
this.col = Math.max(0, this.col - 1);
|
|
107
|
+
}
|
|
108
|
+
moveRow(row) {
|
|
109
|
+
this.row = Math.max(0, row);
|
|
110
|
+
while (this.rows.length <= this.row) this.rows.push(newRow());
|
|
111
|
+
}
|
|
112
|
+
moveRowBy(delta) {
|
|
113
|
+
this.moveRow(this.row + delta);
|
|
114
|
+
}
|
|
115
|
+
setColumn(col) {
|
|
116
|
+
this.col = Math.max(0, col);
|
|
117
|
+
}
|
|
118
|
+
/** `ESC[nK`: 0 cursor to end, 1 start to cursor, 2 whole line. */
|
|
119
|
+
eraseLine(mode) {
|
|
120
|
+
const line = this.line();
|
|
121
|
+
if (mode === 2) this.rows[this.row] = newRow();
|
|
122
|
+
else if (mode === 1) {
|
|
123
|
+
for (let i = 0; i < Math.min(this.col + 1, line.length); i++) line[i] = { ch: " ", style: BLANK };
|
|
124
|
+
} else if (this.col < line.length) line.length = this.col;
|
|
125
|
+
}
|
|
126
|
+
/** `ESC[0J`: erase from the cursor to the end of the screen. */
|
|
127
|
+
eraseBelow() {
|
|
128
|
+
this.eraseLine(0);
|
|
129
|
+
this.rows.length = this.row + 1;
|
|
130
|
+
}
|
|
131
|
+
lines() {
|
|
132
|
+
return this.rows.map((row) => row.segments ??= toSegments(row.cells));
|
|
133
|
+
}
|
|
134
|
+
get rowCount() {
|
|
135
|
+
return this.rows.length;
|
|
136
|
+
}
|
|
137
|
+
get cursorRow() {
|
|
138
|
+
return this.row;
|
|
139
|
+
}
|
|
140
|
+
/** Forgets the first `count` rows (never the cursor's row) and returns how many went. */
|
|
141
|
+
dropTop(count) {
|
|
142
|
+
const n = Math.max(0, Math.min(count, this.row));
|
|
143
|
+
this.rows.splice(0, n);
|
|
144
|
+
this.row -= n;
|
|
145
|
+
return n;
|
|
146
|
+
}
|
|
147
|
+
/** An independent copy, so a partial line can be rendered without committing it. */
|
|
148
|
+
clone() {
|
|
149
|
+
const copy = new _Screen();
|
|
150
|
+
for (const row of this.rows) row.shared = true;
|
|
151
|
+
copy.rows = this.rows.map((r) => ({ ...r }));
|
|
152
|
+
copy.row = this.row;
|
|
153
|
+
copy.col = this.col;
|
|
154
|
+
return copy;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function toSegments(cells) {
|
|
158
|
+
const out = [];
|
|
159
|
+
let current = null;
|
|
160
|
+
let currentStyle = null;
|
|
161
|
+
for (const cell of cells) {
|
|
162
|
+
if (current && currentStyle && current.href === cell.href && styleEquals(currentStyle, cell.style)) {
|
|
163
|
+
current.text += cell.ch;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
current = { text: cell.ch, ...cell.style };
|
|
167
|
+
if (cell.href) current.href = cell.href;
|
|
168
|
+
currentStyle = cell.style;
|
|
169
|
+
out.push(current);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/terminal-output/ansi.ts
|
|
175
|
+
var ESCAPE_SOURCE = "\\x1b(?:\\[([0-?]*)([ -/]*)([@-~])|\\]([^\\x07\\x1b]*)(?:\\x07|\\x1b\\\\)|[ -/]+[0-~]|[0-~])";
|
|
176
|
+
var DEFAULT_SCHEMES = ["http", "https", "file"];
|
|
177
|
+
function stripAnsi(text) {
|
|
178
|
+
return text.replace(new RegExp(ESCAPE_SOURCE, "g"), "");
|
|
179
|
+
}
|
|
180
|
+
function allowedHref(uri, schemes) {
|
|
181
|
+
const match = /^([a-z][a-z0-9+.-]*):/i.exec(uri);
|
|
182
|
+
return match && schemes.includes((match[1] ?? "").toLowerCase()) ? uri : void 0;
|
|
183
|
+
}
|
|
184
|
+
function writeText(screen, state, text) {
|
|
185
|
+
for (const ch of text) {
|
|
186
|
+
if (ch === "\n") screen.newline();
|
|
187
|
+
else if (ch === "\r") screen.carriageReturn();
|
|
188
|
+
else if (ch === "\b") screen.backspace();
|
|
189
|
+
else if (ch === " " || ch >= " " && ch !== "\x7F") screen.write(ch, state.style, state.href);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function applyCsi(screen, state, params, inter, final) {
|
|
193
|
+
if (inter !== "") return;
|
|
194
|
+
if (final === "m") {
|
|
195
|
+
if (/^[0-9;:]*$/.test(params)) state.style = applySgr(state.style, params);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!/^[0-9;]*$/.test(params)) return;
|
|
199
|
+
const n = params === "" ? NaN : Number(params.split(";")[0]);
|
|
200
|
+
const count = Number.isNaN(n) || n === 0 ? 1 : n;
|
|
201
|
+
const mode = Number.isNaN(n) ? 0 : n;
|
|
202
|
+
if (final === "A") screen.moveRowBy(-count);
|
|
203
|
+
else if (final === "B") screen.moveRowBy(count);
|
|
204
|
+
else if (final === "G") screen.setColumn(count - 1);
|
|
205
|
+
else if (final === "K") screen.eraseLine(mode);
|
|
206
|
+
else if (final === "J" && mode === 0) screen.eraseBelow();
|
|
207
|
+
}
|
|
208
|
+
function applyOsc(state, payload, schemes) {
|
|
209
|
+
if (!payload.startsWith("8;")) return;
|
|
210
|
+
const split = payload.indexOf(";", 2);
|
|
211
|
+
state.href = split === -1 ? void 0 : allowedHref(payload.slice(split + 1), schemes);
|
|
212
|
+
}
|
|
213
|
+
function feedAnsi(screen, state, text, options) {
|
|
214
|
+
const schemes = options.linkSchemes ?? DEFAULT_SCHEMES;
|
|
215
|
+
const re = new RegExp(ESCAPE_SOURCE, "g");
|
|
216
|
+
let last = 0;
|
|
217
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
218
|
+
writeText(screen, state, text.slice(last, m.index));
|
|
219
|
+
last = m.index + m[0].length;
|
|
220
|
+
if (m[3] !== void 0) applyCsi(screen, state, m[1] ?? "", m[2] ?? "", m[3]);
|
|
221
|
+
else if (m[4] !== void 0) applyOsc(state, m[4], schemes);
|
|
222
|
+
}
|
|
223
|
+
writeText(screen, state, text.slice(last));
|
|
224
|
+
}
|
|
225
|
+
function run(text, state, options) {
|
|
226
|
+
const screen = new Screen();
|
|
227
|
+
feedAnsi(screen, state, text, options);
|
|
228
|
+
return screen.lines();
|
|
229
|
+
}
|
|
230
|
+
function parseAnsi(text, options = {}) {
|
|
231
|
+
return run(text, { style: {} }, options);
|
|
232
|
+
}
|
|
233
|
+
function createAnsiParser(options = {}) {
|
|
234
|
+
const state = { style: {} };
|
|
235
|
+
return {
|
|
236
|
+
parse: (chunk) => run(chunk, state, options),
|
|
237
|
+
reset: () => {
|
|
238
|
+
state.style = {};
|
|
239
|
+
state.href = void 0;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/terminal-output/log-helpers.ts
|
|
245
|
+
var escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
246
|
+
function queryMatcher(query) {
|
|
247
|
+
const q = query.trim();
|
|
248
|
+
return q ? new RegExp(escapeRegExp(q), "giu") : null;
|
|
249
|
+
}
|
|
250
|
+
function filterLines(lines, query) {
|
|
251
|
+
const matcher = queryMatcher(query);
|
|
252
|
+
const indexes = [];
|
|
253
|
+
lines.forEach((line, i) => {
|
|
254
|
+
if (!matcher) indexes.push(i);
|
|
255
|
+
else {
|
|
256
|
+
matcher.lastIndex = 0;
|
|
257
|
+
if (matcher.test(line)) indexes.push(i);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return indexes;
|
|
261
|
+
}
|
|
262
|
+
function splitByMatch(line, query) {
|
|
263
|
+
const matcher = queryMatcher(query);
|
|
264
|
+
if (!matcher) return [{ text: line, match: false }];
|
|
265
|
+
const segments = [];
|
|
266
|
+
let last = 0;
|
|
267
|
+
for (const m of line.matchAll(matcher)) {
|
|
268
|
+
const start = m.index ?? 0;
|
|
269
|
+
if (start > last) segments.push({ text: line.slice(last, start), match: false });
|
|
270
|
+
segments.push({ text: m[0], match: true });
|
|
271
|
+
last = start + m[0].length;
|
|
272
|
+
}
|
|
273
|
+
if (last < line.length || segments.length === 0) {
|
|
274
|
+
segments.push({ text: line.slice(last), match: false });
|
|
275
|
+
}
|
|
276
|
+
return segments;
|
|
277
|
+
}
|
|
278
|
+
var FAILURE_PATTERNS = [
|
|
279
|
+
{ tool: "tsc", kind: "error", pattern: /\berror TS\d+/ },
|
|
280
|
+
{ tool: "pnpm", kind: "error", pattern: /\bERR_PNPM_[A-Z_]+/ },
|
|
281
|
+
{ tool: "npm", kind: "error", pattern: /\bnpm (?:ERR!|error\b)/ },
|
|
282
|
+
{ tool: "nx", kind: "failed tasks", pattern: /targets failed, including|Failed tasks/ },
|
|
283
|
+
{ tool: "nx", kind: "task failed", pattern: /^\s*✖\s+nx run / },
|
|
284
|
+
{ tool: "nx", kind: "failed", pattern: /^\s*✖\s+\d+\/\d+ failed/ },
|
|
285
|
+
{ tool: "vitest", kind: "fail", pattern: /^\s*FAIL\s/ },
|
|
286
|
+
{ tool: "vitest", kind: "test", pattern: /^\s*[×✗]\s/ },
|
|
287
|
+
{ tool: "docker", kind: "error", pattern: /^\s*(?:Error response from daemon|ERROR: failed to solve|failed to solve|pull access denied)/ },
|
|
288
|
+
{ tool: "docker", kind: "exit", pattern: /^\s*Container \S+\s+(?:Error|exited with code [1-9]\d*)/ },
|
|
289
|
+
{ tool: "generic", kind: "error", pattern: /\b\w*Error\b(?: \[\w+\])?:/ }
|
|
290
|
+
];
|
|
291
|
+
var STACK_FRAME = /^\s*at\s/;
|
|
292
|
+
function findFailures(lines, patterns = FAILURE_PATTERNS) {
|
|
293
|
+
const failures = [];
|
|
294
|
+
lines.forEach((line, index) => {
|
|
295
|
+
if (STACK_FRAME.test(line)) return;
|
|
296
|
+
const hit = patterns.find((p) => p.pattern.test(line));
|
|
297
|
+
if (hit) failures.push({ index, tool: hit.tool, kind: hit.kind });
|
|
298
|
+
});
|
|
299
|
+
return failures;
|
|
300
|
+
}
|
|
301
|
+
function firstFailureIndex(lines, patterns = FAILURE_PATTERNS) {
|
|
302
|
+
return findFailures(lines, patterns)[0]?.index ?? -1;
|
|
303
|
+
}
|
|
304
|
+
var NOISE_PATTERNS = [
|
|
305
|
+
/^\s*[0-9a-f]{12}: (?:Pulling fs layer|Waiting|Downloading|Download complete|Verifying Checksum|Extracting|Pull complete|Already exists)/,
|
|
306
|
+
/^\s*[0-9a-f]{12} (?:Pulling fs layer|Waiting|Downloading|Download complete|Verifying|Extracting|Pull complete|Already exists)/,
|
|
307
|
+
/^\s*Image \S+\s+(?:Pulling|Pulled)\s*$/,
|
|
308
|
+
/^\s*Container \S+\s+(?:Creating|Created|Starting|Started|Running|Waiting|Healthy|Stopping|Stopped|Removing|Removed)\s*$/,
|
|
309
|
+
/^\s*Nx read the output from the cache/
|
|
310
|
+
];
|
|
311
|
+
function isNoise(line, patterns = NOISE_PATTERNS) {
|
|
312
|
+
return patterns.some((p) => p.test(line));
|
|
313
|
+
}
|
|
314
|
+
function tailMeaningful(lines, n, patterns = NOISE_PATTERNS) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
317
|
+
const line = (lines[i] ?? "").trimEnd();
|
|
318
|
+
if (line.trim() && !isNoise(line, patterns)) out.unshift(line);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/terminal-output/follow-tail.ts
|
|
324
|
+
var DEFAULT_TAIL_THRESHOLD = 24;
|
|
325
|
+
function distanceFromEnd({ scrollTop, scrollHeight, clientHeight }) {
|
|
326
|
+
return scrollHeight - scrollTop - clientHeight;
|
|
327
|
+
}
|
|
328
|
+
function isNearEnd(metrics, threshold = DEFAULT_TAIL_THRESHOLD) {
|
|
329
|
+
return distanceFromEnd(metrics) <= threshold;
|
|
330
|
+
}
|
|
331
|
+
function shouldStick(prevAtBottom, metrics, userInitiated, threshold = DEFAULT_TAIL_THRESHOLD) {
|
|
332
|
+
if (!userInitiated) return prevAtBottom;
|
|
333
|
+
return isNearEnd(metrics, threshold);
|
|
334
|
+
}
|
|
335
|
+
function isUserScrollUp({ kind, delta = 0, lastTarget, scrollTop }, threshold = DEFAULT_TAIL_THRESHOLD) {
|
|
336
|
+
if (kind === "wheel") return delta < 0;
|
|
337
|
+
if (kind === "touch") return delta > 0;
|
|
338
|
+
if (kind === "gutter") return true;
|
|
339
|
+
return lastTarget !== null && scrollTop < lastTarget - threshold;
|
|
340
|
+
}
|
|
341
|
+
function unreadDelta(prevCount, nextCount, attached) {
|
|
342
|
+
if (attached) return 0;
|
|
343
|
+
return Math.max(0, nextCount - prevCount);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/terminal-output/use-follow-tail.ts
|
|
347
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
348
|
+
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
349
|
+
function useFollowTail({
|
|
350
|
+
scrollRef,
|
|
351
|
+
itemCount,
|
|
352
|
+
scrollToEnd,
|
|
353
|
+
resetKey,
|
|
354
|
+
threshold = DEFAULT_TAIL_THRESHOLD
|
|
355
|
+
}) {
|
|
356
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
357
|
+
const [unread, setUnread] = useState(0);
|
|
358
|
+
const attached = useRef(true);
|
|
359
|
+
const programmatic = useRef(false);
|
|
360
|
+
const lastTarget = useRef(null);
|
|
361
|
+
const lastTop = useRef(0);
|
|
362
|
+
const frame = useRef(0);
|
|
363
|
+
const prevCount = useRef(itemCount);
|
|
364
|
+
const prevReset = useRef(resetKey);
|
|
365
|
+
const latest = useRef({ scrollToEnd, threshold });
|
|
366
|
+
latest.current = { scrollToEnd, threshold };
|
|
367
|
+
const attach = useCallback((value) => {
|
|
368
|
+
attached.current = value;
|
|
369
|
+
setAtBottom(value);
|
|
370
|
+
if (value) setUnread(0);
|
|
371
|
+
}, []);
|
|
372
|
+
const followEnd = useCallback(() => {
|
|
373
|
+
const el = scrollRef.current;
|
|
374
|
+
if (!el) return;
|
|
375
|
+
programmatic.current = true;
|
|
376
|
+
if (latest.current.scrollToEnd) latest.current.scrollToEnd();
|
|
377
|
+
else el.scrollTop = el.scrollHeight;
|
|
378
|
+
lastTarget.current = el.scrollHeight - el.clientHeight;
|
|
379
|
+
if (typeof requestAnimationFrame === "function") {
|
|
380
|
+
cancelAnimationFrame(frame.current);
|
|
381
|
+
frame.current = requestAnimationFrame(() => {
|
|
382
|
+
programmatic.current = false;
|
|
383
|
+
});
|
|
384
|
+
} else {
|
|
385
|
+
programmatic.current = false;
|
|
386
|
+
}
|
|
387
|
+
}, [scrollRef]);
|
|
388
|
+
useIsomorphicLayoutEffect(() => {
|
|
389
|
+
const count = prevCount.current;
|
|
390
|
+
prevCount.current = itemCount;
|
|
391
|
+
if (prevReset.current !== resetKey) {
|
|
392
|
+
prevReset.current = resetKey;
|
|
393
|
+
attach(true);
|
|
394
|
+
followEnd();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (attached.current) {
|
|
398
|
+
if (itemCount > 0) followEnd();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const added = unreadDelta(count, itemCount, false);
|
|
402
|
+
if (added > 0) setUnread((n) => n + added);
|
|
403
|
+
}, [itemCount, resetKey, attach, followEnd]);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
const el = scrollRef.current;
|
|
406
|
+
if (!el) return;
|
|
407
|
+
const takeOver = () => {
|
|
408
|
+
programmatic.current = false;
|
|
409
|
+
lastTarget.current = null;
|
|
410
|
+
if (attached.current) attach(false);
|
|
411
|
+
};
|
|
412
|
+
const onScroll = () => {
|
|
413
|
+
const { threshold: limit } = latest.current;
|
|
414
|
+
const movedUp = el.scrollTop < lastTop.current;
|
|
415
|
+
lastTop.current = el.scrollTop;
|
|
416
|
+
const info = { kind: "scroll", lastTarget: movedUp ? lastTarget.current : null, scrollTop: el.scrollTop };
|
|
417
|
+
const user = isUserScrollUp(info, limit);
|
|
418
|
+
if (user) takeOver();
|
|
419
|
+
const next = shouldStick(attached.current, el, user || !programmatic.current, limit);
|
|
420
|
+
if (next !== attached.current) attach(next);
|
|
421
|
+
};
|
|
422
|
+
const canScrollUp = () => el.scrollHeight > el.clientHeight && el.scrollTop > 0;
|
|
423
|
+
const onWheel = (e) => {
|
|
424
|
+
if (canScrollUp() && isUserScrollUp({ kind: "wheel", delta: e.deltaY, lastTarget: null, scrollTop: el.scrollTop })) takeOver();
|
|
425
|
+
};
|
|
426
|
+
let touchY = 0;
|
|
427
|
+
const onTouchStart = (e) => {
|
|
428
|
+
touchY = e.touches[0]?.clientY ?? 0;
|
|
429
|
+
};
|
|
430
|
+
const onTouchMove = (e) => {
|
|
431
|
+
const delta = (e.touches[0]?.clientY ?? touchY) - touchY;
|
|
432
|
+
if (canScrollUp() && isUserScrollUp({ kind: "touch", delta, lastTarget: null, scrollTop: el.scrollTop })) takeOver();
|
|
433
|
+
};
|
|
434
|
+
const onPointerDown = (e) => {
|
|
435
|
+
const gutter = el.offsetWidth - el.clientWidth;
|
|
436
|
+
if (e.target !== el || gutter <= 0) return;
|
|
437
|
+
if (e.clientX >= el.getBoundingClientRect().left + el.clientWidth) {
|
|
438
|
+
if (isUserScrollUp({ kind: "gutter", lastTarget: null, scrollTop: el.scrollTop })) takeOver();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
442
|
+
el.addEventListener("wheel", onWheel, { passive: true });
|
|
443
|
+
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
444
|
+
el.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
445
|
+
el.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
446
|
+
return () => {
|
|
447
|
+
el.removeEventListener("scroll", onScroll);
|
|
448
|
+
el.removeEventListener("wheel", onWheel);
|
|
449
|
+
el.removeEventListener("touchstart", onTouchStart);
|
|
450
|
+
el.removeEventListener("touchmove", onTouchMove);
|
|
451
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
452
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame.current);
|
|
453
|
+
};
|
|
454
|
+
}, [scrollRef, attach]);
|
|
455
|
+
const jumpToBottom = useCallback(() => {
|
|
456
|
+
attach(true);
|
|
457
|
+
followEnd();
|
|
458
|
+
}, [attach, followEnd]);
|
|
459
|
+
return { atBottom, unread, jumpToBottom };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export {
|
|
463
|
+
Screen,
|
|
464
|
+
stripAnsi,
|
|
465
|
+
feedAnsi,
|
|
466
|
+
parseAnsi,
|
|
467
|
+
createAnsiParser,
|
|
468
|
+
filterLines,
|
|
469
|
+
splitByMatch,
|
|
470
|
+
FAILURE_PATTERNS,
|
|
471
|
+
findFailures,
|
|
472
|
+
firstFailureIndex,
|
|
473
|
+
NOISE_PATTERNS,
|
|
474
|
+
isNoise,
|
|
475
|
+
tailMeaningful,
|
|
476
|
+
DEFAULT_TAIL_THRESHOLD,
|
|
477
|
+
distanceFromEnd,
|
|
478
|
+
isNearEnd,
|
|
479
|
+
shouldStick,
|
|
480
|
+
isUserScrollUp,
|
|
481
|
+
unreadDelta,
|
|
482
|
+
useFollowTail
|
|
483
|
+
};
|