@runwisp/ui 0.10.0 → 0.12.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/package.json +4 -4
- package/src/lib/assets/runwisp-logo.svg +2 -2
- package/src/lib/components/LogConsole.svelte +518 -68
- package/src/lib/components/Popover.svelte +64 -31
- package/src/lib/components/dashboard/RunDetailPanel.svelte +779 -213
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +441 -235
- package/src/lib/components/dashboard/cron-format.ts +4 -1
- package/src/lib/components/dashboard/run-filters.test.ts +299 -0
- package/src/lib/components/dashboard/run-filters.ts +384 -0
- package/src/lib/components/dashboard/run-helpers.test.ts +33 -0
- package/src/lib/components/dashboard/run-helpers.ts +20 -2
- package/src/lib/index.ts +21 -4
- package/src/lib/log-console/LogCache.svelte.ts +55 -1
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +9 -5
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/lib/utils/ticking-now.svelte.test.ts +45 -0
- package/src/lib/utils/ticking-now.svelte.ts +32 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { untrack } from "svelte";
|
|
6
|
-
import { ansiLineToHtml } from "../log-console/ansi.js";
|
|
6
|
+
import { ansiLineToHtml, visibleColumns } from "../log-console/ansi.js";
|
|
7
7
|
import { LogCache } from "../log-console/LogCache.svelte.js";
|
|
8
8
|
import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
|
|
9
9
|
import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
|
|
@@ -19,6 +19,24 @@
|
|
|
19
19
|
// The pulse is one-shot: changing the prop to a new line restarts the
|
|
20
20
|
// animation; setting it to null clears immediately.
|
|
21
21
|
highlightLine?: number | null;
|
|
22
|
+
// fetchLineHistory resolves the prior whole-region frames a settled
|
|
23
|
+
// progress bar / multi-line redraw passed through, for the given absolute
|
|
24
|
+
// line number. When omitted, anchor lines render without the rewind
|
|
25
|
+
// affordance.
|
|
26
|
+
fetchLineHistory?: ((lineNum: number) => Promise<string[][]>) | undefined;
|
|
27
|
+
// Text of the terminus marker drawn once a finished log's last line is
|
|
28
|
+
// reached (default "end of output"). A stopped/timed-out run passes its
|
|
29
|
+
// own wording so a short capture ends on an explicit reason, not a void.
|
|
30
|
+
endLabel?: string;
|
|
31
|
+
// "muted" (default) renders the terminus in the gutter grey; "warn"
|
|
32
|
+
// tints it amber for runs that ended by intervention rather than naturally.
|
|
33
|
+
endTone?: "muted" | "warn";
|
|
34
|
+
// Wrap long lines instead of horizontally scrolling them. When on, each
|
|
35
|
+
// rendered row's height becomes a multiple of `lineHeight` (one per
|
|
36
|
+
// wrapped visual row), so the virtualizer switches from the fixed-height
|
|
37
|
+
// linear layout to a prefix-sum-of-row-counts layout. Off by default to
|
|
38
|
+
// preserve the original horizontal-scroll behaviour for wide output.
|
|
39
|
+
wrap?: boolean;
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
let {
|
|
@@ -27,8 +45,61 @@
|
|
|
27
45
|
lineHeight = 20,
|
|
28
46
|
class: className = "",
|
|
29
47
|
highlightLine = null,
|
|
48
|
+
fetchLineHistory,
|
|
49
|
+
endLabel = "end of output",
|
|
50
|
+
endTone = "muted",
|
|
51
|
+
wrap = $bindable(false),
|
|
30
52
|
}: Props = $props();
|
|
31
53
|
|
|
54
|
+
// --- Frame-history inline expansion (single expansion at a time) ---
|
|
55
|
+
// expandedLine is the absolute line number whose history block is open;
|
|
56
|
+
// expandedFrames holds the fetched whole-region frames (null while loading).
|
|
57
|
+
let expandedLine = $state<number | null>(null);
|
|
58
|
+
let expandedFrames = $state<string[][] | null>(null);
|
|
59
|
+
let expandedError = $state(false);
|
|
60
|
+
|
|
61
|
+
const FRAME_BLOCK_PAD = 8; // px padding above+below the block content
|
|
62
|
+
const FRAME_GAP = 6; // px between consecutive frames
|
|
63
|
+
const FRAME_BLOCK_MAX = 400; // px cap; the block scrolls internally beyond this
|
|
64
|
+
|
|
65
|
+
// Height the open history block occupies in the virtual surface. Subsequent
|
|
66
|
+
// lines are shifted down by exactly this, so the math stays a single offset.
|
|
67
|
+
let frameBlockHeight = $derived.by(() => {
|
|
68
|
+
if (expandedLine === null) return 0;
|
|
69
|
+
if (!expandedFrames) return lineHeight * 2; // loading / error placeholder
|
|
70
|
+
let h = FRAME_BLOCK_PAD * 2;
|
|
71
|
+
for (const frame of expandedFrames) {
|
|
72
|
+
h += lineHeight; // per-frame label
|
|
73
|
+
h += frame.length * lineHeight; // the frame's rows
|
|
74
|
+
}
|
|
75
|
+
if (expandedFrames.length > 1) h += FRAME_GAP * (expandedFrames.length - 1);
|
|
76
|
+
return Math.min(h, FRAME_BLOCK_MAX);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function collapseHistory() {
|
|
80
|
+
expandedLine = null;
|
|
81
|
+
expandedFrames = null;
|
|
82
|
+
expandedError = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function toggleHistory(lineNum: number) {
|
|
86
|
+
if (expandedLine === lineNum) {
|
|
87
|
+
collapseHistory();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
expandedLine = lineNum;
|
|
91
|
+
expandedFrames = null;
|
|
92
|
+
expandedError = false;
|
|
93
|
+
const fn = fetchLineHistory;
|
|
94
|
+
if (!fn) return;
|
|
95
|
+
try {
|
|
96
|
+
const frames = await fn(lineNum);
|
|
97
|
+
if (expandedLine === lineNum) expandedFrames = frames;
|
|
98
|
+
} catch {
|
|
99
|
+
if (expandedLine === lineNum) expandedError = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
32
103
|
let flashLine = $state<number | null>(null);
|
|
33
104
|
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
105
|
|
|
@@ -55,54 +126,229 @@
|
|
|
55
126
|
|
|
56
127
|
let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
|
|
57
128
|
|
|
58
|
-
|
|
129
|
+
let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
|
|
130
|
+
|
|
131
|
+
// ---- wrap-mode row accounting ---------------------------------------
|
|
132
|
+
// When wrapping, each line occupies as many `lineHeight` rows as its text
|
|
133
|
+
// wraps into at the current column width. rowCounts remembers the wrapped
|
|
134
|
+
// row count for every line whose text has been observed; lines never loaded
|
|
135
|
+
// (pruned or out-of-window) default to 1 row. prefixSums[i] is the total
|
|
136
|
+
// rows consumed by lines [firstAvailableLine, firstAvailableLine + i), so
|
|
137
|
+
// lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
|
|
138
|
+
// O(log n) even though rows are no longer uniform. A plain Map is
|
|
139
|
+
// deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
|
|
140
|
+
// on any change), not by per-key subscriptions — iterating a SvelteMap of
|
|
141
|
+
// tens of thousands of lines on every streamed line would be wasteful.
|
|
142
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
143
|
+
const rowCounts = new Map<number, number>();
|
|
144
|
+
let rowCountVersion = $state(0);
|
|
145
|
+
let prefixSums = $state<number[]>([]);
|
|
146
|
+
|
|
147
|
+
// Right padding on the text cell (pr-4); reserved when computing how many
|
|
148
|
+
// columns a wrapped line can use.
|
|
149
|
+
const TEXT_PADDING = 16;
|
|
150
|
+
|
|
151
|
+
let availableColumns = $derived(
|
|
152
|
+
wrap && charWidth > 0
|
|
153
|
+
? Math.max(1, Math.floor((containerWidth - gutterWidth - TEXT_PADDING) / charWidth))
|
|
154
|
+
: 1,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
function wrappedRowsFor(text: string | undefined): number {
|
|
158
|
+
if (!text || !wrap) return 1;
|
|
159
|
+
const cols = availableColumns;
|
|
160
|
+
if (cols <= 1) return 1;
|
|
161
|
+
return Math.max(1, Math.ceil(visibleColumns(text) / cols));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Line-text white-space model: wrapped lines break anywhere (so a long
|
|
165
|
+
// token wraps mid-word the way a terminal would), unwrapped lines stay on
|
|
166
|
+
// one horizontal-scrolling row.
|
|
167
|
+
let lineTextClass = $derived(wrap ? "break-anywhere whitespace-pre-wrap" : "whitespace-pre");
|
|
168
|
+
|
|
169
|
+
// Recompute row counts for every loaded line whenever wrapping is on and
|
|
170
|
+
// either the cache or the available column width moves. Pruned lines keep
|
|
171
|
+
// their last-computed count so scroll geometry stays stable.
|
|
172
|
+
$effect(() => {
|
|
173
|
+
if (!wrap) return;
|
|
174
|
+
const lines = cache.lines;
|
|
175
|
+
void availableColumns;
|
|
176
|
+
let changed = false;
|
|
177
|
+
for (const [num, text] of lines) {
|
|
178
|
+
const r = wrappedRowsFor(text);
|
|
179
|
+
const prev = rowCounts.get(num);
|
|
180
|
+
if (prev !== r) {
|
|
181
|
+
rowCounts.set(num, r);
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (changed) rowCountVersion++;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Rebuild the prefix-sum table when wrap is on and any geometry input
|
|
189
|
+
// moves. Off ⇒ empty (the fixed-height path ignores it).
|
|
190
|
+
$effect(() => {
|
|
191
|
+
const first = cache.firstAvailableLine;
|
|
192
|
+
const total = cache.totalLines;
|
|
193
|
+
void rowCountVersion;
|
|
194
|
+
void availableColumns;
|
|
195
|
+
if (!wrap) {
|
|
196
|
+
prefixSums = [];
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const count = Math.max(0, total - first);
|
|
200
|
+
const arr = new Array(count + 1);
|
|
201
|
+
arr[0] = 0;
|
|
202
|
+
let acc = 0;
|
|
203
|
+
for (let i = 0; i < count; i++) {
|
|
204
|
+
acc += rowCounts.get(first + i) ?? 1;
|
|
205
|
+
arr[i + 1] = acc;
|
|
206
|
+
}
|
|
207
|
+
prefixSums = arr;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Drop wrap geometry when wrapping turns off so memory doesn't linger.
|
|
211
|
+
$effect(() => {
|
|
212
|
+
if (!wrap) {
|
|
213
|
+
rowCounts.clear();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function lineRowCount(lineNum: number): number {
|
|
218
|
+
return wrap ? (rowCounts.get(lineNum) ?? 1) : 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Convert a global line number to a pixel Y position. Lines below an open
|
|
222
|
+
// history block are pushed down by the block's height (single expansion, so
|
|
223
|
+
// it's one conditional offset on top of the wrap/non-wrap row math).
|
|
59
224
|
function lineTop(lineNum: number): number {
|
|
60
|
-
|
|
225
|
+
const base =
|
|
226
|
+
truncationBannerHeight +
|
|
227
|
+
(expandedLine !== null && lineNum > expandedLine ? frameBlockHeight : 0);
|
|
228
|
+
if (!wrap) {
|
|
229
|
+
return (lineNum - cache.firstAvailableLine) * lineHeight + base;
|
|
230
|
+
}
|
|
231
|
+
const i = lineNum - cache.firstAvailableLine;
|
|
232
|
+
if (i <= 0) return base;
|
|
233
|
+
if (i >= prefixSums.length) {
|
|
234
|
+
const last = prefixSums.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
|
|
235
|
+
return last * lineHeight + base;
|
|
236
|
+
}
|
|
237
|
+
return (prefixSums[i] ?? 0) * lineHeight + base;
|
|
61
238
|
}
|
|
62
239
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
240
|
+
function lineHeightPx(lineNum: number): number {
|
|
241
|
+
return lineRowCount(lineNum) * lineHeight;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Largest i with prefixSums[i] <= yRows (i.e. the line index whose row span
|
|
245
|
+
// covers the given scroll offset, in rows). Used to map a scroll position
|
|
246
|
+
// back to a line number when wrapping makes the layout non-linear.
|
|
247
|
+
function lineIndexAtY(yRows: number): number {
|
|
248
|
+
const arr = prefixSums;
|
|
249
|
+
if (arr.length === 0) return 0;
|
|
250
|
+
if (yRows <= (arr[0] ?? 0)) return 0;
|
|
251
|
+
const last = arr.length - 1;
|
|
252
|
+
if (yRows >= (arr[last] ?? Number.POSITIVE_INFINITY)) return last;
|
|
253
|
+
let lo = 0;
|
|
254
|
+
let hi = last;
|
|
255
|
+
while (lo < hi) {
|
|
256
|
+
const mid = (lo + hi + 1) >> 1;
|
|
257
|
+
if ((arr[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
|
|
258
|
+
else hi = mid - 1;
|
|
259
|
+
}
|
|
260
|
+
return lo;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let visibleStart = $derived.by(() => {
|
|
264
|
+
const first = cache.firstAvailableLine;
|
|
265
|
+
if (!wrap) {
|
|
266
|
+
return Math.max(
|
|
267
|
+
first,
|
|
268
|
+
first +
|
|
269
|
+
Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
|
|
81
270
|
OVERSCAN,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
274
|
+
const idx = lineIndexAtY(yRows) - OVERSCAN;
|
|
275
|
+
return Math.max(first, first + Math.max(0, idx));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let visibleEnd = $derived.by(() => {
|
|
279
|
+
const first = cache.firstAvailableLine;
|
|
280
|
+
const maxLine = Math.max(0, cache.totalLines - 1);
|
|
281
|
+
if (!wrap) {
|
|
282
|
+
return Math.max(
|
|
283
|
+
first,
|
|
284
|
+
Math.min(
|
|
285
|
+
maxLine,
|
|
286
|
+
first +
|
|
287
|
+
Math.ceil(
|
|
288
|
+
Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
|
|
289
|
+
lineHeight,
|
|
290
|
+
) +
|
|
291
|
+
OVERSCAN,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
296
|
+
const viewportRows = containerHeight / lineHeight;
|
|
297
|
+
const limit = yRows + viewportRows + OVERSCAN;
|
|
298
|
+
const count = prefixSums.length - 1;
|
|
299
|
+
let end = lineIndexAtY(yRows);
|
|
300
|
+
while (end < count && (prefixSums[end] ?? Number.POSITIVE_INFINITY) < limit) end++;
|
|
301
|
+
return Math.min(maxLine, first + Math.max(0, end));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Live-region overlay rows, rendered in place below the committed lines.
|
|
305
|
+
let overlayRows = $derived(cache.overlayRows);
|
|
85
306
|
|
|
86
307
|
let totalHeight = $derived.by(() => {
|
|
87
|
-
const availableLines = cache.totalLines - cache.firstAvailableLine;
|
|
88
308
|
const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
|
|
89
|
-
const linesHeight =
|
|
90
|
-
|
|
309
|
+
const linesHeight = wrap
|
|
310
|
+
? (prefixSums.length > 0
|
|
311
|
+
? (prefixSums[prefixSums.length - 1] ?? 0)
|
|
312
|
+
: Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
313
|
+
lineHeight +
|
|
314
|
+
truncationBannerHeight
|
|
315
|
+
: (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
|
|
316
|
+
const overlayHeight = overlayRows.length * lineHeight;
|
|
317
|
+
// Reserve a row for the streaming cursor only when it stands on its own
|
|
318
|
+
// fresh line. With a live tail it rides the last overlay row (counted in
|
|
319
|
+
// overlayHeight), so no extra row is needed.
|
|
320
|
+
const streamingHeight = isStreaming && overlayRows.length === 0 ? lineHeight : 0;
|
|
321
|
+
// A finished log gets an "end of output" sentinel where the streaming
|
|
322
|
+
// indicator sits while live (mutually exclusive). It's drawn two rows
|
|
323
|
+
// tall so the dashed rule has breathing room above the centred label.
|
|
324
|
+
const sentinelHeight = cache.finished && cache.totalLines > 0 ? lineHeight * 2 : 0;
|
|
91
325
|
const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
|
|
92
|
-
return Math.max(
|
|
326
|
+
return Math.max(
|
|
327
|
+
linesHeight +
|
|
328
|
+
overlayHeight +
|
|
329
|
+
streamingHeight +
|
|
330
|
+
sentinelHeight +
|
|
331
|
+
blankHeight +
|
|
332
|
+
frameBlockHeight,
|
|
333
|
+
containerHeight,
|
|
334
|
+
);
|
|
93
335
|
});
|
|
94
336
|
|
|
95
|
-
let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
|
|
96
|
-
|
|
97
337
|
// Width of the virtual scroll surface: wide enough for the longest line
|
|
98
338
|
// seen so far, never narrower than the viewport (so short logs show no
|
|
99
|
-
// horizontal scrollbar).
|
|
339
|
+
// horizontal scrollbar). Wrapping pins the surface to the viewport — there
|
|
340
|
+
// is no horizontal axis to scroll.
|
|
100
341
|
let surfaceWidth = $derived(
|
|
101
|
-
|
|
342
|
+
wrap
|
|
343
|
+
? containerWidth
|
|
344
|
+
: Math.max(
|
|
345
|
+
containerWidth,
|
|
346
|
+
gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING,
|
|
347
|
+
),
|
|
102
348
|
);
|
|
103
349
|
|
|
104
350
|
let renderedLines = $derived.by(() => {
|
|
105
|
-
const lines: Array<{ num: number; text: string | undefined }> = [];
|
|
351
|
+
const lines: Array<{ num: number; text: string | undefined; frameCount: number }> = [];
|
|
106
352
|
const start = Math.max(cache.firstAvailableLine, visibleStart);
|
|
107
353
|
const end = Math.min(cache.totalLines - 1, visibleEnd);
|
|
108
354
|
|
|
@@ -110,11 +356,15 @@
|
|
|
110
356
|
lines.push({
|
|
111
357
|
num: i,
|
|
112
358
|
text: cache.lines.get(i),
|
|
359
|
+
frameCount: cache.frameCounts.get(i) ?? 0,
|
|
113
360
|
});
|
|
114
361
|
}
|
|
115
362
|
return lines;
|
|
116
363
|
});
|
|
117
364
|
|
|
365
|
+
// Anchors are clickable only when the parent supplied a history fetcher.
|
|
366
|
+
let canExpand = $derived(fetchLineHistory !== undefined);
|
|
367
|
+
|
|
118
368
|
// The fetcher serves on-demand scroll-up loads only; the parent seeds
|
|
119
369
|
// the initial cache via onStream (typically a tail fetch) so the
|
|
120
370
|
// viewport lands at the end of the log without racing the auto-scroll.
|
|
@@ -150,14 +400,16 @@
|
|
|
150
400
|
untrack(() => cache.prune(vs, ve));
|
|
151
401
|
});
|
|
152
402
|
|
|
153
|
-
let
|
|
403
|
+
let prevTailRows = 0; // plain variable — intentionally non-reactive
|
|
154
404
|
|
|
155
405
|
$effect(() => {
|
|
156
|
-
|
|
157
|
-
|
|
406
|
+
// Track committed lines AND live overlay rows so an animating region at
|
|
407
|
+
// the tail keeps a bottom-anchored viewport pinned to the bottom.
|
|
408
|
+
const currentTail = cache.totalLines + overlayRows.length;
|
|
409
|
+
if (currentTail > prevTailRows && isAutoScroll && !userScrolledUp) {
|
|
158
410
|
requestAnimationFrame(() => scrollToBottom());
|
|
159
411
|
}
|
|
160
|
-
|
|
412
|
+
prevTailRows = currentTail;
|
|
161
413
|
});
|
|
162
414
|
|
|
163
415
|
function onScroll(e: Event & { currentTarget: EventTarget & HTMLDivElement }) {
|
|
@@ -203,6 +455,13 @@
|
|
|
203
455
|
if (containerEl) {
|
|
204
456
|
containerHeight = containerEl.clientHeight;
|
|
205
457
|
containerWidth = containerEl.clientWidth;
|
|
458
|
+
// Resync the reactive scroll position with the DOM. The browser
|
|
459
|
+
// clamps scrollTop when the viewport grows (e.g. maximizing the
|
|
460
|
+
// console), but no scroll event fires for that clamp — so without
|
|
461
|
+
// this the virtualizer keeps a stale, too-large scrollTop and
|
|
462
|
+
// renders an empty window until the first manual scroll.
|
|
463
|
+
scrollTop = containerEl.scrollTop;
|
|
464
|
+
if (isAutoScroll && !userScrolledUp) scrollToBottom();
|
|
206
465
|
}
|
|
207
466
|
measureCharWidth();
|
|
208
467
|
}
|
|
@@ -231,6 +490,7 @@
|
|
|
231
490
|
|
|
232
491
|
export function reset() {
|
|
233
492
|
cache.reset();
|
|
493
|
+
collapseHistory();
|
|
234
494
|
isAutoScroll = true;
|
|
235
495
|
userScrolledUp = false;
|
|
236
496
|
scrollTop = 0;
|
|
@@ -277,8 +537,16 @@
|
|
|
277
537
|
});
|
|
278
538
|
</script>
|
|
279
539
|
|
|
540
|
+
<!-- One place that renders ANSI-converted markup, so the necessary @html escape
|
|
541
|
+
hatch (the input is sanitised by ansiLineToHtml) lives behind a single
|
|
542
|
+
audited disable rather than being repeated per call site. -->
|
|
543
|
+
{#snippet ansiLine(text: string)}
|
|
544
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
545
|
+
<span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
|
|
546
|
+
{/snippet}
|
|
547
|
+
|
|
280
548
|
<div
|
|
281
|
-
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-
|
|
549
|
+
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-[var(--rw-con-bg)] font-mono text-[12.5px] {className}"
|
|
282
550
|
>
|
|
283
551
|
<!-- Hidden ruler: one monospace column is measured from this off-screen
|
|
284
552
|
sample to size the horizontal scroll surface without per-line reflow. -->
|
|
@@ -313,60 +581,172 @@
|
|
|
313
581
|
{/if}
|
|
314
582
|
|
|
315
583
|
{#each renderedLines as line (line.num)}
|
|
584
|
+
{@const isAnchor = canExpand && line.frameCount > 0}
|
|
316
585
|
<div
|
|
317
|
-
class="log-line absolute right-0 left-0 flex
|
|
586
|
+
class="log-line absolute right-0 left-0 flex {wrap
|
|
587
|
+
? 'items-start'
|
|
588
|
+
: 'items-center'} hover:bg-[rgb(255_255_255_/_0.025)] {flashLine ===
|
|
318
589
|
line.num
|
|
319
590
|
? 'log-line--flash'
|
|
320
591
|
: ''}"
|
|
321
|
-
style="top: {lineTop(line.num)}px; height: {
|
|
592
|
+
style="top: {lineTop(line.num)}px; height: {lineHeightPx(line.num)}px;"
|
|
322
593
|
>
|
|
323
594
|
<div
|
|
324
|
-
class="sticky left-0 z-10 flex-shrink-0 bg-
|
|
595
|
+
class="sticky left-0 z-10 flex flex-shrink-0 items-center justify-end gap-1 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
325
596
|
style="width: {gutterWidth}px;"
|
|
326
597
|
>
|
|
598
|
+
{#if isAnchor}
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
class="frame-toggle {expandedLine === line.num
|
|
602
|
+
? 'text-aurora-400'
|
|
603
|
+
: 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
|
|
604
|
+
title="{line.frameCount} earlier frame{line.frameCount === 1
|
|
605
|
+
? ''
|
|
606
|
+
: 's'} — click to {expandedLine === line.num
|
|
607
|
+
? 'hide'
|
|
608
|
+
: 'rewind'}"
|
|
609
|
+
aria-label="Toggle frame history for line {line.num + 1}"
|
|
610
|
+
aria-expanded={expandedLine === line.num}
|
|
611
|
+
onclick={() => toggleHistory(line.num)}
|
|
612
|
+
>
|
|
613
|
+
↻
|
|
614
|
+
</button>
|
|
615
|
+
{/if}
|
|
327
616
|
{line.num + 1}
|
|
328
617
|
</div>
|
|
329
|
-
<div class="flex-1 pr-4 text-
|
|
618
|
+
<div class="min-w-0 flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
330
619
|
{#if line.text !== undefined}
|
|
331
620
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
332
|
-
<span class=
|
|
621
|
+
<span class={lineTextClass}>{@html ansiLineToHtml(line.text)}</span>
|
|
333
622
|
{:else}
|
|
334
|
-
<span class="text-
|
|
623
|
+
<span class="text-[var(--rw-con-dim)] italic">Loading...</span>
|
|
335
624
|
{/if}
|
|
336
625
|
</div>
|
|
337
626
|
</div>
|
|
338
627
|
{/each}
|
|
339
628
|
|
|
340
|
-
{#if
|
|
629
|
+
{#if expandedLine !== null}
|
|
630
|
+
<div
|
|
631
|
+
class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
|
|
632
|
+
style="top: {lineTop(expandedLine) +
|
|
633
|
+
lineHeightPx(expandedLine)}px; height: {frameBlockHeight}px;"
|
|
634
|
+
>
|
|
635
|
+
<div style="padding: {FRAME_BLOCK_PAD}px 0;">
|
|
636
|
+
{#if expandedFrames}
|
|
637
|
+
{#each expandedFrames as frame, fi (fi)}
|
|
638
|
+
<div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
|
|
639
|
+
<div
|
|
640
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
|
|
641
|
+
style="height: {lineHeight}px; line-height: {lineHeight}px;"
|
|
642
|
+
>
|
|
643
|
+
Frame {fi + 1} of {expandedFrames.length}
|
|
644
|
+
</div>
|
|
645
|
+
{#each frame as row, ri (ri)}
|
|
646
|
+
<div
|
|
647
|
+
class="flex items-center"
|
|
648
|
+
style="height: {lineHeight}px;"
|
|
649
|
+
>
|
|
650
|
+
<div
|
|
651
|
+
class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
652
|
+
style="width: {gutterWidth}px;"
|
|
653
|
+
></div>
|
|
654
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
655
|
+
{@render ansiLine(row)}
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
{/each}
|
|
659
|
+
</div>
|
|
660
|
+
{/each}
|
|
661
|
+
{:else}
|
|
662
|
+
<div
|
|
663
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] italic"
|
|
664
|
+
style="line-height: {lineHeight}px; padding-left: {gutterWidth}px;"
|
|
665
|
+
>
|
|
666
|
+
{expandedError ? "Failed to load frame history" : "Loading frames…"}
|
|
667
|
+
</div>
|
|
668
|
+
{/if}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
{/if}
|
|
672
|
+
|
|
673
|
+
{#each overlayRows as row, i (i)}
|
|
674
|
+
<div
|
|
675
|
+
class="log-line log-line--live absolute right-0 left-0 flex items-center"
|
|
676
|
+
style="top: {lineTop(cache.totalLines + i)}px; height: {lineHeight}px;"
|
|
677
|
+
>
|
|
678
|
+
<div
|
|
679
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
680
|
+
style="width: {gutterWidth}px;"
|
|
681
|
+
>
|
|
682
|
+
⋯
|
|
683
|
+
</div>
|
|
684
|
+
<!-- The cursor rides the end of the last live overlay row so a
|
|
685
|
+
still-being-appended line shows the caret inline, the way a
|
|
686
|
+
terminal parks it before the next byte arrives. -->
|
|
687
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
688
|
+
{@render ansiLine(
|
|
689
|
+
row,
|
|
690
|
+
)}{#if !cache.finished && i === overlayRows.length - 1}<span
|
|
691
|
+
class="stream-cursor"
|
|
692
|
+
aria-hidden="true"
|
|
693
|
+
></span>{/if}
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
{/each}
|
|
697
|
+
|
|
698
|
+
<!-- Streaming cursor on a fresh line: shown only when the last
|
|
699
|
+
committed line ended with a newline (no live tail). When output
|
|
700
|
+
is still being appended mid-line, the cursor rides the end of the
|
|
701
|
+
last overlay row instead (see the overlay loop above). -->
|
|
702
|
+
{#if isStreaming && overlayRows.length === 0}
|
|
341
703
|
<div
|
|
342
704
|
class="streaming-indicator absolute right-0 left-0 flex items-center"
|
|
343
705
|
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
|
|
344
706
|
>
|
|
345
707
|
<div
|
|
346
|
-
class="sticky left-0 z-10 flex-shrink-0 bg-
|
|
708
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
347
709
|
style="width: {gutterWidth}px;"
|
|
348
|
-
></div>
|
|
349
|
-
<div
|
|
350
|
-
class="sticky flex items-center gap-1 text-mist-400"
|
|
351
|
-
style="left: {gutterWidth}px;"
|
|
352
710
|
>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
711
|
+
{cache.totalLines + 1}
|
|
712
|
+
</div>
|
|
713
|
+
<div class="flex-1 pr-4">
|
|
714
|
+
<span class="stream-cursor" aria-hidden="true"></span>
|
|
356
715
|
</div>
|
|
357
716
|
</div>
|
|
358
717
|
{/if}
|
|
359
718
|
|
|
719
|
+
{#if cache.finished && cache.totalLines > 0}
|
|
720
|
+
<div
|
|
721
|
+
class="sentinel absolute right-4 left-4 flex justify-center select-none"
|
|
722
|
+
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight * 2}px;"
|
|
723
|
+
>
|
|
724
|
+
<span
|
|
725
|
+
class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
|
|
726
|
+
'warn'
|
|
727
|
+
? 'sentinel--warn'
|
|
728
|
+
: 'sentinel--muted'}"
|
|
729
|
+
>
|
|
730
|
+
── {endLabel} ──
|
|
731
|
+
</span>
|
|
732
|
+
</div>
|
|
733
|
+
{/if}
|
|
734
|
+
|
|
360
735
|
{#if cache.totalLines > 0}
|
|
361
736
|
{#each Array.from({ length: BLANK_LINES_AT_END }, (_, i) => i) as i (i)}
|
|
362
737
|
<div
|
|
363
738
|
class="absolute right-0 left-0 flex items-center"
|
|
364
739
|
style="top: {lineTop(
|
|
365
|
-
cache.
|
|
740
|
+
cache.finished
|
|
741
|
+
? cache.totalLines + 2 + i
|
|
742
|
+
: cache.totalLines +
|
|
743
|
+
overlayRows.length +
|
|
744
|
+
(isStreaming && overlayRows.length === 0 ? 1 : 0) +
|
|
745
|
+
i,
|
|
366
746
|
)}px; height: {lineHeight}px;"
|
|
367
747
|
>
|
|
368
748
|
<div
|
|
369
|
-
class="sticky left-0 z-10 flex-shrink-0 bg-
|
|
749
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right opacity-0 select-none"
|
|
370
750
|
style="width: {gutterWidth}px;"
|
|
371
751
|
>
|
|
372
752
|
~
|
|
@@ -375,17 +755,21 @@
|
|
|
375
755
|
{/each}
|
|
376
756
|
{/if}
|
|
377
757
|
|
|
378
|
-
{#if cache.totalLines === 0 && !fetcher?.isFetching}
|
|
379
|
-
<div
|
|
758
|
+
{#if cache.totalLines === 0 && overlayRows.length === 0 && !fetcher?.isFetching}
|
|
759
|
+
<div
|
|
760
|
+
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
761
|
+
>
|
|
380
762
|
<div class="text-center">
|
|
381
763
|
<div class="mb-1 text-lg">No output yet</div>
|
|
382
|
-
<div class="text-sm text-
|
|
764
|
+
<div class="text-sm text-[var(--rw-con-gutter)]">Waiting for logs...</div>
|
|
383
765
|
</div>
|
|
384
766
|
</div>
|
|
385
767
|
{/if}
|
|
386
768
|
|
|
387
769
|
{#if cache.totalLines === 0 && fetcher?.isFetching}
|
|
388
|
-
<div
|
|
770
|
+
<div
|
|
771
|
+
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
772
|
+
>
|
|
389
773
|
<div class="flex items-center gap-2">
|
|
390
774
|
<span class="dot" style="animation-delay: 0ms;"></span>
|
|
391
775
|
<span class="dot" style="animation-delay: 150ms;"></span>
|
|
@@ -400,11 +784,11 @@
|
|
|
400
784
|
{#if userScrolledUp && cache.totalLines > 0}
|
|
401
785
|
<button
|
|
402
786
|
class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-full border
|
|
403
|
-
border-
|
|
787
|
+
border-[rgb(255_255_255_/_0.1)] bg-[var(--rw-con-panel)]
|
|
404
788
|
px-3 py-1.5 text-xs
|
|
405
|
-
font-medium text-
|
|
789
|
+
font-medium text-[var(--rw-con-text)] shadow-lg
|
|
406
790
|
transition-all duration-200 hover:scale-105
|
|
407
|
-
hover:bg-
|
|
791
|
+
hover:bg-[rgb(255_255_255_/_0.06)] active:scale-95"
|
|
408
792
|
onclick={enableAutoScroll}
|
|
409
793
|
>
|
|
410
794
|
<svg
|
|
@@ -421,20 +805,20 @@
|
|
|
421
805
|
{/if}
|
|
422
806
|
|
|
423
807
|
<div
|
|
424
|
-
class="flex flex-shrink-0 items-center justify-between border-t border-
|
|
425
|
-
bg-
|
|
808
|
+
class="flex flex-shrink-0 items-center justify-between border-t border-[rgb(255_255_255_/_0.06)]
|
|
809
|
+
bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] text-[var(--rw-con-dim)]"
|
|
426
810
|
>
|
|
427
811
|
<div class="flex items-center gap-3">
|
|
428
812
|
{#if isStreaming}
|
|
429
|
-
<div class="flex items-center gap-1.5 text-
|
|
813
|
+
<div class="flex items-center gap-1.5 text-aurora-400">
|
|
430
814
|
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
|
|
431
815
|
Streaming
|
|
432
816
|
</div>
|
|
433
817
|
{:else if cache.finished}
|
|
434
|
-
<span
|
|
818
|
+
<span>Stream ended</span>
|
|
435
819
|
{/if}
|
|
436
820
|
{#if fetcher?.isFetching}
|
|
437
|
-
<span
|
|
821
|
+
<span>Fetching...</span>
|
|
438
822
|
{/if}
|
|
439
823
|
</div>
|
|
440
824
|
<div class="flex items-center gap-4">
|
|
@@ -443,7 +827,21 @@
|
|
|
443
827
|
<span>{formatBytes(cache.totalBytes)}</span>
|
|
444
828
|
{/if}
|
|
445
829
|
{#if isAutoScroll}
|
|
446
|
-
<span class="text-
|
|
830
|
+
<span class="inline-flex items-center gap-1.5 text-[#7fe0b0]">
|
|
831
|
+
<svg
|
|
832
|
+
class="h-3 w-3"
|
|
833
|
+
viewBox="0 0 24 24"
|
|
834
|
+
fill="none"
|
|
835
|
+
stroke="currentColor"
|
|
836
|
+
stroke-width="2.2"
|
|
837
|
+
stroke-linecap="round"
|
|
838
|
+
stroke-linejoin="round"
|
|
839
|
+
>
|
|
840
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
841
|
+
<polyline points="6 13 12 19 18 13" />
|
|
842
|
+
</svg>
|
|
843
|
+
Auto-scroll
|
|
844
|
+
</span>
|
|
447
845
|
{/if}
|
|
448
846
|
</div>
|
|
449
847
|
</div>
|
|
@@ -455,10 +853,31 @@
|
|
|
455
853
|
tab-size: 8;
|
|
456
854
|
}
|
|
457
855
|
|
|
856
|
+
/* End-of-output marker: a centred label sitting under a dashed rule, the
|
|
857
|
+
way the approved design closes a finished capture. */
|
|
858
|
+
.sentinel--muted {
|
|
859
|
+
color: var(--rw-con-gutter);
|
|
860
|
+
border-top-color: rgb(255 255 255 / 0.08);
|
|
861
|
+
}
|
|
862
|
+
.sentinel--warn {
|
|
863
|
+
color: #e5a552;
|
|
864
|
+
border-top-color: rgb(229 165 82 / 0.3);
|
|
865
|
+
}
|
|
866
|
+
|
|
458
867
|
.log-line--flash {
|
|
459
868
|
animation: log-line-flash 1.5s ease-out;
|
|
460
869
|
}
|
|
461
870
|
|
|
871
|
+
.frame-toggle {
|
|
872
|
+
cursor: pointer;
|
|
873
|
+
font-size: 0.85em;
|
|
874
|
+
line-height: 1;
|
|
875
|
+
background: transparent;
|
|
876
|
+
border: none;
|
|
877
|
+
padding: 0;
|
|
878
|
+
transition: color 120ms ease;
|
|
879
|
+
}
|
|
880
|
+
|
|
462
881
|
@keyframes log-line-flash {
|
|
463
882
|
0% {
|
|
464
883
|
background-color: oklch(0.8 0.18 70 / 0.45);
|
|
@@ -468,6 +887,37 @@
|
|
|
468
887
|
}
|
|
469
888
|
}
|
|
470
889
|
|
|
890
|
+
/* Blinking teal block caret shown while a run is actively streaming. One
|
|
891
|
+
monospace cell wide so it reads as a terminal cursor parked at the write
|
|
892
|
+
position — inline after a still-appending line, or alone on the next. */
|
|
893
|
+
.stream-cursor {
|
|
894
|
+
display: inline-block;
|
|
895
|
+
width: 1ch;
|
|
896
|
+
height: 1.1em;
|
|
897
|
+
vertical-align: text-bottom;
|
|
898
|
+
background-color: var(--color-aurora-400);
|
|
899
|
+
animation: stream-cursor-blink 1.05s steps(2, start) infinite;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
@keyframes stream-cursor-blink {
|
|
903
|
+
0%,
|
|
904
|
+
50% {
|
|
905
|
+
opacity: 1;
|
|
906
|
+
}
|
|
907
|
+
50.01%,
|
|
908
|
+
100% {
|
|
909
|
+
opacity: 0;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/* Reduced motion: keep the caret solid (still a clear "live" marker) rather
|
|
914
|
+
than blinking. */
|
|
915
|
+
@media (prefers-reduced-motion: reduce) {
|
|
916
|
+
.stream-cursor {
|
|
917
|
+
animation: none;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
471
921
|
.dot {
|
|
472
922
|
display: inline-block;
|
|
473
923
|
width: 4px;
|