@runwisp/ui 1.5.0 → 1.7.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 +5 -7
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +4 -25
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/DataGrid.svelte +5 -5
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +3 -7
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +101 -424
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +3 -2
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +267 -545
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +10 -1
- package/src/lib/components/dashboard/cron-format.ts +7 -3
- package/src/lib/components/dashboard/run-filters.test.ts +62 -2
- package/src/lib/components/dashboard/run-filters.ts +104 -99
- package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
- package/src/lib/components/dashboard/run-helpers.ts +150 -44
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +13 -13
- package/src/lib/utils/format.test.ts +125 -3
- package/src/lib/utils/format.ts +101 -8
- package/src/lib/utils/toast.svelte.ts +2 -3
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { untrack } from "svelte";
|
|
6
|
-
import { ansiLineToHtml
|
|
6
|
+
import { ansiLineToHtml } from "../log-console/ansi.js";
|
|
7
|
+
import AnsiLine from "../log-console/AnsiLine.svelte";
|
|
8
|
+
import FrameHistoryBlock from "../log-console/FrameHistoryBlock.svelte";
|
|
9
|
+
import LogConsoleFooter from "../log-console/LogConsoleFooter.svelte";
|
|
10
|
+
import { FrameHistory } from "../log-console/frame-history.svelte.js";
|
|
7
11
|
import { LogCache } from "../log-console/LogCache.svelte.js";
|
|
8
12
|
import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
|
|
9
13
|
import { createHighlightScroll } from "../log-console/highlight-scroll.js";
|
|
10
14
|
import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
|
|
11
|
-
import {
|
|
15
|
+
import { LINE_HEIGHT as lineHeight, WrapLayout } from "../log-console/wrap-layout.svelte.js";
|
|
12
16
|
|
|
13
17
|
interface Props {
|
|
14
18
|
fetchLogs?: FetchLogsFn;
|
|
@@ -60,56 +64,7 @@
|
|
|
60
64
|
wrap = $bindable(false),
|
|
61
65
|
}: Props = $props();
|
|
62
66
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
// --- Frame-history inline expansion (single expansion at a time) ---
|
|
66
|
-
// expandedLine is the absolute line number whose history block is open;
|
|
67
|
-
// expandedFrames holds the fetched whole-region frames (null while loading).
|
|
68
|
-
let expandedLine = $state<number | null>(null);
|
|
69
|
-
let expandedFrames = $state<string[][] | null>(null);
|
|
70
|
-
let expandedError = $state(false);
|
|
71
|
-
|
|
72
|
-
const FRAME_BLOCK_PAD = 8; // px padding above+below the block content
|
|
73
|
-
const FRAME_GAP = 6; // px between consecutive frames
|
|
74
|
-
const FRAME_BLOCK_MAX = 400; // px cap; the block scrolls internally beyond this
|
|
75
|
-
|
|
76
|
-
// Height the open history block occupies in the virtual surface. Subsequent
|
|
77
|
-
// lines are shifted down by exactly this, so the math stays a single offset.
|
|
78
|
-
let frameBlockHeight = $derived.by(() => {
|
|
79
|
-
if (expandedLine === null) return 0;
|
|
80
|
-
if (!expandedFrames) return lineHeight * 2; // loading / error placeholder
|
|
81
|
-
let h = FRAME_BLOCK_PAD * 2;
|
|
82
|
-
for (const frame of expandedFrames) {
|
|
83
|
-
h += lineHeight; // per-frame label
|
|
84
|
-
h += frame.length * lineHeight; // the frame's rows
|
|
85
|
-
}
|
|
86
|
-
if (expandedFrames.length > 1) h += FRAME_GAP * (expandedFrames.length - 1);
|
|
87
|
-
return Math.min(h, FRAME_BLOCK_MAX);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
function collapseHistory() {
|
|
91
|
-
expandedLine = null;
|
|
92
|
-
expandedFrames = null;
|
|
93
|
-
expandedError = false;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function toggleHistory(lineNum: number) {
|
|
97
|
-
if (expandedLine === lineNum) {
|
|
98
|
-
collapseHistory();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
expandedLine = lineNum;
|
|
102
|
-
expandedFrames = null;
|
|
103
|
-
expandedError = false;
|
|
104
|
-
const fn = fetchLineHistory;
|
|
105
|
-
if (!fn) return;
|
|
106
|
-
try {
|
|
107
|
-
const frames = await fn(lineNum);
|
|
108
|
-
if (expandedLine === lineNum) expandedFrames = frames;
|
|
109
|
-
} catch {
|
|
110
|
-
if (expandedLine === lineNum) expandedError = true;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
67
|
+
const frames = new FrameHistory(() => fetchLineHistory);
|
|
113
68
|
|
|
114
69
|
let flashLine = $state<number | null>(null);
|
|
115
70
|
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -124,8 +79,9 @@
|
|
|
124
79
|
// Monospace character width in px, measured from a hidden ruler so the
|
|
125
80
|
// horizontal scroll surface is sized without per-line DOM reflow.
|
|
126
81
|
let charWidth = $state(8);
|
|
127
|
-
|
|
128
|
-
|
|
82
|
+
// True while the viewport is pinned to the end of the log: new lines scroll
|
|
83
|
+
// into view. Scrolling away from the bottom turns it off.
|
|
84
|
+
let followTail = $state(true);
|
|
129
85
|
let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
|
|
130
86
|
|
|
131
87
|
const OVERSCAN = 50;
|
|
@@ -139,216 +95,63 @@
|
|
|
139
95
|
|
|
140
96
|
let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
|
|
141
97
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
|
|
149
|
-
// O(log n) even though rows are no longer uniform. A plain Map is
|
|
150
|
-
// deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
|
|
151
|
-
// on any change), not by per-key subscriptions — iterating a SvelteMap of
|
|
152
|
-
// tens of thousands of lines on every streamed line would be wasteful.
|
|
153
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
154
|
-
const rowCounts = new Map<number, number>();
|
|
155
|
-
let rowCountVersion = $state(0);
|
|
156
|
-
let prefixSums = $state<number[]>([]);
|
|
157
|
-
|
|
158
|
-
// Right padding on the text cell (pr-4); reserved when computing how many
|
|
159
|
-
// columns a wrapped line can use.
|
|
160
|
-
const TEXT_PADDING = 16;
|
|
161
|
-
|
|
162
|
-
let availableColumns = $derived(
|
|
163
|
-
wrap && charWidth > 0
|
|
164
|
-
? Math.max(1, Math.floor((containerWidth - gutterWidth - TEXT_PADDING) / charWidth))
|
|
165
|
-
: 1,
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
function wrappedRowsFor(text: string | undefined): number {
|
|
169
|
-
if (!text || !wrap) return 1;
|
|
170
|
-
const cols = availableColumns;
|
|
171
|
-
if (cols <= 1) return 1;
|
|
172
|
-
return Math.max(1, Math.ceil(visibleColumns(text) / cols));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Measures wrapped row counts for exactly the lines in [min, max] and bumps
|
|
176
|
-
// rowCountVersion if anything changed. Called directly wherever new line
|
|
177
|
-
// text lands in the cache (streamed append, on-demand backfill), each of
|
|
178
|
-
// which already knows the touched range from LogCache's merge result — so
|
|
179
|
-
// a single appended line costs O(1) here instead of re-walking the whole
|
|
180
|
-
// (up to 50k-line) cache. Plain function, not an $effect: reads/writes
|
|
181
|
-
// rowCounts and cache.lines by key, never by iterating the map.
|
|
182
|
-
function measureRange(min: number, max: number) {
|
|
183
|
-
if (!wrap || min > max) return;
|
|
184
|
-
let changed = false;
|
|
185
|
-
for (let num = min; num <= max; num++) {
|
|
186
|
-
const r = wrappedRowsFor(cache.lines.get(num));
|
|
187
|
-
if (rowCounts.get(num) !== r) {
|
|
188
|
-
rowCounts.set(num, r);
|
|
189
|
-
changed = true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (changed) rowCountVersion++;
|
|
193
|
-
}
|
|
98
|
+
const layout = new WrapLayout(cache, {
|
|
99
|
+
wrap: () => wrap,
|
|
100
|
+
width: () => containerWidth,
|
|
101
|
+
gutterWidth: () => gutterWidth,
|
|
102
|
+
charWidth: () => charWidth,
|
|
103
|
+
});
|
|
194
104
|
|
|
195
105
|
// Line-text white-space model: wrapped lines break anywhere (so a long
|
|
196
106
|
// token wraps mid-word the way a terminal would), unwrapped lines stay on
|
|
197
107
|
// one horizontal-scrolling row.
|
|
198
108
|
let lineTextClass = $derived(wrap ? "break-anywhere whitespace-pre-wrap" : "whitespace-pre");
|
|
199
109
|
|
|
200
|
-
// Full remeasure of every loaded line, needed only when wrapping turns on
|
|
201
|
-
// or the available column width changes (wrap width affects every line's
|
|
202
|
-
// row count, so there's no way to scope that to a range). Line
|
|
203
|
-
// appends/backfills are measured incrementally at their call sites via
|
|
204
|
-
// measureRange instead — reading `cache.lines` in this effect's tracked
|
|
205
|
-
// scope would resubscribe it to the SvelteMap's shared version signal and
|
|
206
|
-
// re-run this full O(n) walk on every single appended line. `untrack`
|
|
207
|
-
// (same pattern as the fetch/prune effects below) keeps that read out of
|
|
208
|
-
// the dependency set, so only `wrap`/`availableColumns` retrigger this.
|
|
209
|
-
// Pruned lines keep their last-computed count so scroll geometry stays
|
|
210
|
-
// stable.
|
|
211
|
-
$effect(() => {
|
|
212
|
-
if (!wrap) return;
|
|
213
|
-
void availableColumns;
|
|
214
|
-
untrack(() => {
|
|
215
|
-
let changed = false;
|
|
216
|
-
for (const [num, text] of cache.lines) {
|
|
217
|
-
const r = wrappedRowsFor(text);
|
|
218
|
-
if (rowCounts.get(num) !== r) {
|
|
219
|
-
rowCounts.set(num, r);
|
|
220
|
-
changed = true;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (changed) rowCountVersion++;
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// Rebuild the prefix-sum table when wrap is on and any geometry input
|
|
228
|
-
// moves. Off ⇒ empty (the fixed-height path ignores it).
|
|
229
|
-
$effect(() => {
|
|
230
|
-
const first = cache.firstAvailableLine;
|
|
231
|
-
const total = cache.totalLines;
|
|
232
|
-
void rowCountVersion;
|
|
233
|
-
void availableColumns;
|
|
234
|
-
if (!wrap) {
|
|
235
|
-
prefixSums = [];
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const count = Math.max(0, total - first);
|
|
239
|
-
const arr = new Array(count + 1);
|
|
240
|
-
arr[0] = 0;
|
|
241
|
-
let acc = 0;
|
|
242
|
-
for (let i = 0; i < count; i++) {
|
|
243
|
-
acc += rowCounts.get(first + i) ?? 1;
|
|
244
|
-
arr[i + 1] = acc;
|
|
245
|
-
}
|
|
246
|
-
prefixSums = arr;
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// Drop wrap geometry when wrapping turns off so memory doesn't linger.
|
|
250
|
-
$effect(() => {
|
|
251
|
-
if (!wrap) {
|
|
252
|
-
rowCounts.clear();
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
function lineRowCount(lineNum: number): number {
|
|
257
|
-
return wrap ? (rowCounts.get(lineNum) ?? 1) : 1;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
110
|
// Convert a global line number to a pixel Y position. Lines below an open
|
|
261
|
-
// history block are pushed down by the block's height (single expansion
|
|
262
|
-
// it's one conditional offset on top of the wrap/non-wrap row math).
|
|
111
|
+
// history block are pushed down by the block's height (single expansion).
|
|
263
112
|
function lineTop(lineNum: number): number {
|
|
264
113
|
const base =
|
|
265
114
|
truncationBannerHeight +
|
|
266
|
-
(
|
|
267
|
-
|
|
268
|
-
return (lineNum - cache.firstAvailableLine) * lineHeight + base;
|
|
269
|
-
}
|
|
270
|
-
const i = lineNum - cache.firstAvailableLine;
|
|
271
|
-
if (i <= 0) return base;
|
|
272
|
-
if (i >= prefixSums.length) {
|
|
273
|
-
const last = prefixSums.at(-1) ?? 0;
|
|
274
|
-
return last * lineHeight + base;
|
|
275
|
-
}
|
|
276
|
-
return (prefixSums[i] ?? 0) * lineHeight + base;
|
|
115
|
+
(frames.line !== null && lineNum > frames.line ? frames.blockHeight : 0);
|
|
116
|
+
return layout.rowsAbove(lineNum) * lineHeight + base;
|
|
277
117
|
}
|
|
278
118
|
|
|
279
119
|
function lineHeightPx(lineNum: number): number {
|
|
280
|
-
return
|
|
120
|
+
return layout.rowCount(lineNum) * lineHeight;
|
|
281
121
|
}
|
|
282
122
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
// back to a line number when wrapping makes the layout non-linear.
|
|
286
|
-
function lineIndexAtY(yRows: number): number {
|
|
287
|
-
const arr = prefixSums;
|
|
288
|
-
if (arr.length === 0) return 0;
|
|
289
|
-
if (yRows <= (arr[0] ?? 0)) return 0;
|
|
290
|
-
const last = arr.length - 1;
|
|
291
|
-
if (yRows >= (arr[last] ?? Number.POSITIVE_INFINITY)) return last;
|
|
292
|
-
let lo = 0;
|
|
293
|
-
let hi = last;
|
|
294
|
-
while (lo < hi) {
|
|
295
|
-
const mid = (lo + hi + 1) >> 1;
|
|
296
|
-
if ((arr[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
|
|
297
|
-
else hi = mid - 1;
|
|
298
|
-
}
|
|
299
|
-
return lo;
|
|
300
|
-
}
|
|
123
|
+
// Scroll offset past the truncation banner, in rows.
|
|
124
|
+
let scrollRows = $derived(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight);
|
|
301
125
|
|
|
302
|
-
let visibleStart = $derived
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
|
|
309
|
-
OVERSCAN,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
313
|
-
const idx = lineIndexAtY(yRows) - OVERSCAN;
|
|
314
|
-
return Math.max(first, first + Math.max(0, idx));
|
|
315
|
-
});
|
|
126
|
+
let visibleStart = $derived(
|
|
127
|
+
Math.max(
|
|
128
|
+
cache.firstAvailableLine,
|
|
129
|
+
cache.firstAvailableLine + layout.offsetAt(scrollRows) - OVERSCAN,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
316
132
|
|
|
317
|
-
let visibleEnd = $derived
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
OVERSCAN,
|
|
331
|
-
),
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
335
|
-
const viewportRows = containerHeight / lineHeight;
|
|
336
|
-
const limit = yRows + viewportRows + OVERSCAN;
|
|
337
|
-
const count = prefixSums.length - 1;
|
|
338
|
-
let end = lineIndexAtY(yRows);
|
|
339
|
-
while (end < count && (prefixSums[end] ?? Number.POSITIVE_INFINITY) < limit) end++;
|
|
340
|
-
return Math.min(maxLine, first + Math.max(0, end));
|
|
341
|
-
});
|
|
133
|
+
let visibleEnd = $derived(
|
|
134
|
+
Math.max(
|
|
135
|
+
cache.firstAvailableLine,
|
|
136
|
+
Math.min(
|
|
137
|
+
Math.max(0, cache.totalLines - 1),
|
|
138
|
+
cache.firstAvailableLine +
|
|
139
|
+
layout.offsetEnd(
|
|
140
|
+
scrollRows,
|
|
141
|
+
scrollRows + containerHeight / lineHeight + OVERSCAN,
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
342
146
|
|
|
343
147
|
// Live-region overlay rows, rendered in place below the committed lines.
|
|
344
148
|
let overlayRows = $derived(cache.overlayRows);
|
|
345
149
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
: (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
|
|
150
|
+
// Height of the log itself; the scroll surface (totalHeight) is never
|
|
151
|
+
// shorter than the viewport. Exposed as data-content-height so a page can
|
|
152
|
+
// tell when the whole log fits without scrolling.
|
|
153
|
+
let contentHeight = $derived.by(() => {
|
|
154
|
+
const linesHeight = layout.totalRows * lineHeight + truncationBannerHeight;
|
|
352
155
|
const overlayHeight = overlayRows.length * lineHeight;
|
|
353
156
|
// Reserve a row for the streaming cursor only when it stands on its own
|
|
354
157
|
// fresh line. With a live tail it rides the last overlay row (counted in
|
|
@@ -359,20 +162,20 @@
|
|
|
359
162
|
// tall so the dashed rule has breathing room above the centred label.
|
|
360
163
|
const sentinelHeight = cache.finished && cache.totalLines > 0 ? lineHeight * 2 : 0;
|
|
361
164
|
const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
|
|
362
|
-
return
|
|
165
|
+
return (
|
|
363
166
|
linesHeight +
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
containerHeight,
|
|
167
|
+
overlayHeight +
|
|
168
|
+
streamingHeight +
|
|
169
|
+
sentinelHeight +
|
|
170
|
+
blankHeight +
|
|
171
|
+
frames.blockHeight
|
|
370
172
|
);
|
|
371
173
|
});
|
|
174
|
+
let totalHeight = $derived(Math.max(contentHeight, containerHeight));
|
|
372
175
|
|
|
373
176
|
// Width of the virtual scroll surface: wide enough for the longest line
|
|
374
177
|
// seen so far, never narrower than the viewport (so short logs show no
|
|
375
|
-
// horizontal scrollbar). Wrapping pins the surface to the viewport
|
|
178
|
+
// horizontal scrollbar). Wrapping pins the surface to the viewport, there
|
|
376
179
|
// is no horizontal axis to scroll.
|
|
377
180
|
let surfaceWidth = $derived(
|
|
378
181
|
wrap
|
|
@@ -407,10 +210,8 @@
|
|
|
407
210
|
$effect(() => {
|
|
408
211
|
const fn = fetchLogs;
|
|
409
212
|
fetcher = new LogFetcher(cache, fn, (min, max) => {
|
|
410
|
-
measureRange(min, max);
|
|
411
|
-
if (
|
|
412
|
-
requestAnimationFrame(() => scrollToBottom());
|
|
413
|
-
}
|
|
213
|
+
layout.measureRange(min, max);
|
|
214
|
+
if (followTail) requestAnimationFrame(scrollToBottom);
|
|
414
215
|
});
|
|
415
216
|
|
|
416
217
|
return () => {
|
|
@@ -429,22 +230,20 @@
|
|
|
429
230
|
}
|
|
430
231
|
});
|
|
431
232
|
|
|
432
|
-
// Prune stale cache entries
|
|
233
|
+
// Prune stale cache entries, separate from reads to avoid a read-write cycle on SvelteMap.
|
|
433
234
|
$effect(() => {
|
|
434
235
|
const vs = visibleStart;
|
|
435
236
|
const ve = visibleEnd;
|
|
436
237
|
untrack(() => cache.prune(vs, ve));
|
|
437
238
|
});
|
|
438
239
|
|
|
439
|
-
let prevTailRows = 0; // plain variable
|
|
240
|
+
let prevTailRows = 0; // plain variable, intentionally non-reactive
|
|
440
241
|
|
|
441
242
|
$effect(() => {
|
|
442
243
|
// Track committed lines AND live overlay rows so an animating region at
|
|
443
244
|
// the tail keeps a bottom-anchored viewport pinned to the bottom.
|
|
444
245
|
const currentTail = cache.totalLines + overlayRows.length;
|
|
445
|
-
if (currentTail > prevTailRows &&
|
|
446
|
-
requestAnimationFrame(() => scrollToBottom());
|
|
447
|
-
}
|
|
246
|
+
if (currentTail > prevTailRows && followTail) requestAnimationFrame(scrollToBottom);
|
|
448
247
|
prevTailRows = currentTail;
|
|
449
248
|
});
|
|
450
249
|
|
|
@@ -452,38 +251,16 @@
|
|
|
452
251
|
const target = e.currentTarget;
|
|
453
252
|
scrollTop = target.scrollTop;
|
|
454
253
|
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
const currentScroll = target.scrollTop;
|
|
458
|
-
const distanceFromBottom = scrollHeight - currentScroll - clientHeight;
|
|
459
|
-
|
|
460
|
-
const atBottom = distanceFromBottom < lineHeight * 2;
|
|
461
|
-
|
|
462
|
-
if (atBottom) {
|
|
463
|
-
userScrolledUp = false;
|
|
464
|
-
isAutoScroll = true;
|
|
465
|
-
} else if (isAutoScroll) {
|
|
466
|
-
userScrolledUp = true;
|
|
467
|
-
isAutoScroll = false;
|
|
468
|
-
}
|
|
254
|
+
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
255
|
+
followTail = distanceFromBottom < lineHeight * 2;
|
|
469
256
|
}
|
|
470
257
|
|
|
471
|
-
function scrollToBottom(
|
|
472
|
-
if (containerEl)
|
|
473
|
-
if (smooth) {
|
|
474
|
-
containerEl.scrollTo({
|
|
475
|
-
top: containerEl.scrollHeight,
|
|
476
|
-
behavior: "smooth",
|
|
477
|
-
});
|
|
478
|
-
} else {
|
|
479
|
-
containerEl.scrollTop = containerEl.scrollHeight;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
258
|
+
function scrollToBottom() {
|
|
259
|
+
if (containerEl) containerEl.scrollTop = containerEl.scrollHeight;
|
|
482
260
|
}
|
|
483
261
|
|
|
484
262
|
function enableAutoScroll() {
|
|
485
|
-
|
|
486
|
-
userScrolledUp = false;
|
|
263
|
+
followTail = true;
|
|
487
264
|
scrollToBottom();
|
|
488
265
|
}
|
|
489
266
|
|
|
@@ -493,11 +270,11 @@
|
|
|
493
270
|
containerWidth = containerEl.clientWidth;
|
|
494
271
|
// Resync the reactive scroll position with the DOM. The browser
|
|
495
272
|
// clamps scrollTop when the viewport grows (e.g. maximizing the
|
|
496
|
-
// console), but no scroll event fires for that clamp
|
|
273
|
+
// console), but no scroll event fires for that clamp, so without
|
|
497
274
|
// this the virtualizer keeps a stale, too-large scrollTop and
|
|
498
275
|
// renders an empty window until the first manual scroll.
|
|
499
276
|
scrollTop = containerEl.scrollTop;
|
|
500
|
-
if (
|
|
277
|
+
if (followTail) scrollToBottom();
|
|
501
278
|
}
|
|
502
279
|
measureCharWidth();
|
|
503
280
|
}
|
|
@@ -514,11 +291,9 @@
|
|
|
514
291
|
export function onStream(event: LogEvent) {
|
|
515
292
|
const prevTotal = cache.totalLines;
|
|
516
293
|
const merged = cache.applyEvent(event);
|
|
517
|
-
if (merged.touched) measureRange(merged.min, merged.max);
|
|
294
|
+
if (merged.touched) layout.measureRange(merged.min, merged.max);
|
|
518
295
|
|
|
519
|
-
if (cache.totalLines > prevTotal &&
|
|
520
|
-
requestAnimationFrame(() => scrollToBottom());
|
|
521
|
-
}
|
|
296
|
+
if (cache.totalLines > prevTotal && followTail) requestAnimationFrame(scrollToBottom);
|
|
522
297
|
|
|
523
298
|
if (fetcher) {
|
|
524
299
|
fetcher.maybeRequestMissing(visibleStart, visibleEnd);
|
|
@@ -527,9 +302,8 @@
|
|
|
527
302
|
|
|
528
303
|
export function reset() {
|
|
529
304
|
cache.reset();
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
userScrolledUp = false;
|
|
305
|
+
frames.collapse();
|
|
306
|
+
followTail = true;
|
|
533
307
|
scrollTop = 0;
|
|
534
308
|
}
|
|
535
309
|
|
|
@@ -545,8 +319,7 @@
|
|
|
545
319
|
const targetY = lineTop(line) - containerHeight / 2 + lineHeight / 2;
|
|
546
320
|
const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
|
|
547
321
|
containerEl.scrollTo({ top: clamped, behavior: "smooth" });
|
|
548
|
-
|
|
549
|
-
userScrolledUp = true;
|
|
322
|
+
followTail = false;
|
|
550
323
|
flashLine = line;
|
|
551
324
|
if (flashTimer !== null) clearTimeout(flashTimer);
|
|
552
325
|
flashTimer = setTimeout(() => {
|
|
@@ -577,14 +350,6 @@
|
|
|
577
350
|
});
|
|
578
351
|
</script>
|
|
579
352
|
|
|
580
|
-
<!-- One place that renders ANSI-converted markup, so the necessary @html escape
|
|
581
|
-
hatch (the input is sanitised by ansiLineToHtml) lives behind a single
|
|
582
|
-
audited disable rather than being repeated per call site. -->
|
|
583
|
-
{#snippet ansiLine(text: string)}
|
|
584
|
-
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
585
|
-
<span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
|
|
586
|
-
{/snippet}
|
|
587
|
-
|
|
588
353
|
<div
|
|
589
354
|
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-[var(--rw-con-bg)] font-mono text-[12.5px] {className}"
|
|
590
355
|
>
|
|
@@ -606,7 +371,12 @@
|
|
|
606
371
|
</div>
|
|
607
372
|
{/if}
|
|
608
373
|
|
|
609
|
-
<div
|
|
374
|
+
<div
|
|
375
|
+
bind:this={containerEl}
|
|
376
|
+
class="flex-1 overflow-auto"
|
|
377
|
+
onscroll={onScroll}
|
|
378
|
+
data-content-height={contentHeight}
|
|
379
|
+
>
|
|
610
380
|
<div
|
|
611
381
|
class="relative"
|
|
612
382
|
style="height: {totalHeight}px; min-height: 100%; width: {surfaceWidth}px; min-width: 100%;"
|
|
@@ -644,17 +414,15 @@
|
|
|
644
414
|
{#if isAnchor}
|
|
645
415
|
<button
|
|
646
416
|
type="button"
|
|
647
|
-
class="frame-toggle {
|
|
417
|
+
class="frame-toggle {frames.line === line.num
|
|
648
418
|
? 'text-aurora-400'
|
|
649
419
|
: 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
|
|
650
420
|
title="{line.frameCount} earlier frame{line.frameCount === 1
|
|
651
421
|
? ''
|
|
652
|
-
: 's'}
|
|
653
|
-
? 'hide'
|
|
654
|
-
: 'rewind'}"
|
|
422
|
+
: 's'}, click to {frames.line === line.num ? 'hide' : 'rewind'}"
|
|
655
423
|
aria-label="Toggle frame history for line {line.num + 1}"
|
|
656
|
-
aria-expanded={
|
|
657
|
-
onclick={() =>
|
|
424
|
+
aria-expanded={frames.line === line.num}
|
|
425
|
+
onclick={() => frames.toggle(line.num)}
|
|
658
426
|
>
|
|
659
427
|
↻
|
|
660
428
|
</button>
|
|
@@ -672,48 +440,12 @@
|
|
|
672
440
|
</div>
|
|
673
441
|
{/each}
|
|
674
442
|
|
|
675
|
-
{#if
|
|
676
|
-
<
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
<div style="padding: {FRAME_BLOCK_PAD}px 0;">
|
|
682
|
-
{#if expandedFrames}
|
|
683
|
-
{#each expandedFrames as frame, fi (fi)}
|
|
684
|
-
<div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
|
|
685
|
-
<div
|
|
686
|
-
class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
|
|
687
|
-
style="height: {lineHeight}px; line-height: {lineHeight}px;"
|
|
688
|
-
>
|
|
689
|
-
Frame {fi + 1} of {expandedFrames.length}
|
|
690
|
-
</div>
|
|
691
|
-
{#each frame as row, ri (ri)}
|
|
692
|
-
<div
|
|
693
|
-
class="flex items-center"
|
|
694
|
-
style="height: {lineHeight}px;"
|
|
695
|
-
>
|
|
696
|
-
<div
|
|
697
|
-
class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
698
|
-
style="width: {gutterWidth}px;"
|
|
699
|
-
></div>
|
|
700
|
-
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
701
|
-
{@render ansiLine(row)}
|
|
702
|
-
</div>
|
|
703
|
-
</div>
|
|
704
|
-
{/each}
|
|
705
|
-
</div>
|
|
706
|
-
{/each}
|
|
707
|
-
{:else}
|
|
708
|
-
<div
|
|
709
|
-
class="px-3 text-xs text-[var(--rw-con-dim)] italic"
|
|
710
|
-
style="line-height: {lineHeight}px; padding-left: {gutterWidth}px;"
|
|
711
|
-
>
|
|
712
|
-
{expandedError ? "Failed to load frame history" : "Loading frames…"}
|
|
713
|
-
</div>
|
|
714
|
-
{/if}
|
|
715
|
-
</div>
|
|
716
|
-
</div>
|
|
443
|
+
{#if frames.line !== null}
|
|
444
|
+
<FrameHistoryBlock
|
|
445
|
+
history={frames}
|
|
446
|
+
top={lineTop(frames.line) + lineHeightPx(frames.line)}
|
|
447
|
+
{gutterWidth}
|
|
448
|
+
/>
|
|
717
449
|
{/if}
|
|
718
450
|
|
|
719
451
|
{#each overlayRows as row, i (i)}
|
|
@@ -731,9 +463,9 @@
|
|
|
731
463
|
still-being-appended line shows the caret inline, the way a
|
|
732
464
|
terminal parks it before the next byte arrives. -->
|
|
733
465
|
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
734
|
-
|
|
735
|
-
row
|
|
736
|
-
|
|
466
|
+
<AnsiLine
|
|
467
|
+
text={row}
|
|
468
|
+
/>{#if !cache.finished && i === overlayRows.length - 1}<span
|
|
737
469
|
class="stream-cursor"
|
|
738
470
|
aria-hidden="true"
|
|
739
471
|
></span>{/if}
|
|
@@ -827,74 +559,19 @@
|
|
|
827
559
|
</div>
|
|
828
560
|
</div>
|
|
829
561
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
<svg
|
|
840
|
-
class="h-3.5 w-3.5"
|
|
841
|
-
viewBox="0 0 24 24"
|
|
842
|
-
fill="none"
|
|
843
|
-
stroke="currentColor"
|
|
844
|
-
stroke-width="2"
|
|
845
|
-
>
|
|
846
|
-
<path d="M12 5v14M19 12l-7 7-7-7" />
|
|
847
|
-
</svg>
|
|
848
|
-
Scroll to bottom
|
|
849
|
-
</button>
|
|
850
|
-
{/if}
|
|
851
|
-
|
|
852
|
-
<div
|
|
853
|
-
class="flex flex-shrink-0 items-center justify-between border-t border-[var(--rw-con-gutter)]
|
|
854
|
-
bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] tracking-wide text-[var(--rw-con-dim)]"
|
|
855
|
-
>
|
|
856
|
-
<div class="flex items-center gap-3">
|
|
857
|
-
{#if isStreaming}
|
|
858
|
-
<div class="flex items-center gap-1.5 text-aurora-400">
|
|
859
|
-
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
|
|
860
|
-
Streaming
|
|
861
|
-
</div>
|
|
862
|
-
{:else if cache.finished}
|
|
863
|
-
<span>Stream ended</span>
|
|
864
|
-
{/if}
|
|
865
|
-
{#if fetcher?.isFetching}
|
|
866
|
-
<span>Fetching...</span>
|
|
867
|
-
{/if}
|
|
868
|
-
</div>
|
|
869
|
-
<div class="flex items-center gap-4">
|
|
870
|
-
<span>{cache.totalLines.toLocaleString()} lines</span>
|
|
871
|
-
{#if cache.totalBytes > 0}
|
|
872
|
-
<span>{formatBytes(cache.totalBytes)}</span>
|
|
873
|
-
{/if}
|
|
874
|
-
{#if isAutoScroll}
|
|
875
|
-
<span class="inline-flex items-center gap-1.5 text-term-ok">
|
|
876
|
-
<svg
|
|
877
|
-
class="h-3 w-3"
|
|
878
|
-
viewBox="0 0 24 24"
|
|
879
|
-
fill="none"
|
|
880
|
-
stroke="currentColor"
|
|
881
|
-
stroke-width="2.2"
|
|
882
|
-
stroke-linecap="round"
|
|
883
|
-
stroke-linejoin="round"
|
|
884
|
-
>
|
|
885
|
-
<line x1="12" y1="5" x2="12" y2="19" />
|
|
886
|
-
<polyline points="6 13 12 19 18 13" />
|
|
887
|
-
</svg>
|
|
888
|
-
Auto-scroll
|
|
889
|
-
</span>
|
|
890
|
-
{/if}
|
|
891
|
-
</div>
|
|
892
|
-
</div>
|
|
562
|
+
<LogConsoleFooter
|
|
563
|
+
{followTail}
|
|
564
|
+
streaming={isStreaming}
|
|
565
|
+
finished={cache.finished}
|
|
566
|
+
fetching={fetcher?.isFetching ?? false}
|
|
567
|
+
totalLines={cache.totalLines}
|
|
568
|
+
totalBytes={cache.totalBytes}
|
|
569
|
+
onFollow={enableAutoScroll}
|
|
570
|
+
/>
|
|
893
571
|
</div>
|
|
894
572
|
|
|
895
573
|
<style>
|
|
896
574
|
.log-console {
|
|
897
|
-
--log-line-height: 20px;
|
|
898
575
|
tab-size: 8;
|
|
899
576
|
}
|
|
900
577
|
|
|
@@ -909,7 +586,7 @@
|
|
|
909
586
|
border-top-color: color-mix(in srgb, var(--rw-term-warn) 30%, transparent);
|
|
910
587
|
}
|
|
911
588
|
|
|
912
|
-
/* Subtle row hover on the fixed-dark console surface
|
|
589
|
+
/* Subtle row hover on the fixed-dark console surface, a faint lift of the
|
|
913
590
|
console text colour, never a theme-flipping fill. */
|
|
914
591
|
.log-line:hover {
|
|
915
592
|
background-color: color-mix(in srgb, var(--rw-con-text) 4%, transparent);
|
|
@@ -939,7 +616,7 @@
|
|
|
939
616
|
|
|
940
617
|
/* Blinking teal block caret shown while a run is actively streaming. One
|
|
941
618
|
monospace cell wide so it reads as a terminal cursor parked at the write
|
|
942
|
-
position
|
|
619
|
+
position, inline after a still-appending line, or alone on the next. */
|
|
943
620
|
.stream-cursor {
|
|
944
621
|
display: inline-block;
|
|
945
622
|
width: 1ch;
|