@runwisp/ui 1.5.1 → 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.
Files changed (53) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  43. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  44. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  45. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  46. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  47. package/src/lib/utils/format.test.ts +122 -0
  48. package/src/lib/utils/format.ts +97 -4
  49. package/src/lib/utils/typing-target.ts +10 -0
  50. package/src/stories/Accordion.stories.svelte +60 -1
  51. package/src/stories/CodeBlock.stories.svelte +13 -0
  52. package/src/stories/Link.stories.svelte +25 -1
  53. 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, visibleColumns } from "../log-console/ansi.js";
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 { formatBytes } from "../utils/format.js";
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 lineHeight = 20; // px per rendered row
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
- let isAutoScroll = $state(true);
128
- let userScrolledUp = $state(false);
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
- // ---- wrap-mode row accounting ---------------------------------------
143
- // When wrapping, each line occupies as many `lineHeight` rows as its text
144
- // wraps into at the current column width. rowCounts remembers the wrapped
145
- // row count for every line whose text has been observed; lines never loaded
146
- // (pruned or out-of-window) default to 1 row. prefixSums[i] is the total
147
- // rows consumed by lines [firstAvailableLine, firstAvailableLine + i), so
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, so
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
- (expandedLine !== null && lineNum > expandedLine ? frameBlockHeight : 0);
267
- if (!wrap) {
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 lineRowCount(lineNum) * lineHeight;
120
+ return layout.rowCount(lineNum) * lineHeight;
281
121
  }
282
122
 
283
- // Largest i with prefixSums[i] <= yRows (i.e. the line index whose row span
284
- // covers the given scroll offset, in rows). Used to map a scroll position
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.by(() => {
303
- const first = cache.firstAvailableLine;
304
- if (!wrap) {
305
- return Math.max(
306
- first,
307
- first +
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.by(() => {
318
- const first = cache.firstAvailableLine;
319
- const maxLine = Math.max(0, cache.totalLines - 1);
320
- if (!wrap) {
321
- return Math.max(
322
- first,
323
- Math.min(
324
- maxLine,
325
- first +
326
- Math.ceil(
327
- Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
328
- lineHeight,
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
- let totalHeight = $derived.by(() => {
347
- const linesHeight = wrap
348
- ? (prefixSums.at(-1) ?? Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
349
- lineHeight +
350
- truncationBannerHeight
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,16 +162,16 @@
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 Math.max(
165
+ return (
363
166
  linesHeight +
364
- overlayHeight +
365
- streamingHeight +
366
- sentinelHeight +
367
- blankHeight +
368
- frameBlockHeight,
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
@@ -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 (isAutoScroll && !userScrolledUp) {
412
- requestAnimationFrame(() => scrollToBottom());
413
- }
213
+ layout.measureRange(min, max);
214
+ if (followTail) requestAnimationFrame(scrollToBottom);
414
215
  });
415
216
 
416
217
  return () => {
@@ -442,9 +243,7 @@
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 && isAutoScroll && !userScrolledUp) {
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 scrollHeight = target.scrollHeight;
456
- const clientHeight = target.clientHeight;
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(smooth = false) {
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
- isAutoScroll = true;
486
- userScrolledUp = false;
263
+ followTail = true;
487
264
  scrollToBottom();
488
265
  }
489
266
 
@@ -497,7 +274,7 @@
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 (isAutoScroll && !userScrolledUp) scrollToBottom();
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 && isAutoScroll && !userScrolledUp) {
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
- collapseHistory();
531
- isAutoScroll = true;
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
- isAutoScroll = false;
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 bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
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 {expandedLine === line.num
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'}, click to {expandedLine === line.num
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={expandedLine === line.num}
657
- onclick={() => toggleHistory(line.num)}
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 expandedLine !== null}
676
- <div
677
- class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
678
- style="top: {lineTop(expandedLine) +
679
- lineHeightPx(expandedLine)}px; height: {frameBlockHeight}px;"
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
- {@render ansiLine(
735
- row,
736
- )}{#if !cache.finished && i === overlayRows.length - 1}<span
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
- {#if userScrolledUp && cache.totalLines > 0}
831
- <button
832
- class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-[3px] border
833
- border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]
834
- px-3 py-1.5 text-xs
835
- text-[var(--rw-con-text)]
836
- hover:border-term-teal hover:text-term-teal active:translate-y-[1px]"
837
- onclick={enableAutoScroll}
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