@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.
@@ -0,0 +1,384 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { FAILURE_END_REASONS, TRIGGERS, type Trigger } from "@runwisp/common";
5
+
6
+ export type RunsListSortDirection = "asc" | "desc" | "";
7
+
8
+ /**
9
+ * The single filter shape shared by the runs list, the filter popover, the
10
+ * SSE-merge source, and the bulk selector. Every field beyond `search` and
11
+ * `sort_direction` is an optional dimension applied server-side; the zero
12
+ * value (`emptyRunFilters`) matches every run.
13
+ *
14
+ * `statuses` is a multi-select set joined to a comma-separated string only at
15
+ * the two wire boundaries (the list query and the bulk selector). Time bounds
16
+ * are absolute RFC3339 instants — presets resolve to `now − delta` at apply
17
+ * time so a live SSE row created after the boundary keeps matching.
18
+ */
19
+ export interface RunsListFilters {
20
+ search: string;
21
+ statuses: string[];
22
+ sort_direction: RunsListSortDirection;
23
+ // Optional dimensions explicitly admit `undefined` so a dimension can be
24
+ // cleared by reassignment (`{ ...f, x: undefined }`) under the project's
25
+ // exactOptionalPropertyTypes — a fresh object reference is what re-triggers
26
+ // the parent's fetch effect through several levels of `bind:`.
27
+ task_name?: string | undefined;
28
+ created_after?: string | undefined;
29
+ created_before?: string | undefined;
30
+ triggered_by?: string | undefined;
31
+ // A human exit-code expression — a bare code (`137`), a comparison (`>100`,
32
+ // `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
33
+ // inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
34
+ // server only ever sees `exit_code_min` / `exit_code_max`.
35
+ exit_code?: string | undefined;
36
+ retries_only?: boolean | undefined;
37
+ }
38
+
39
+ /** A fresh default filter — no dimensions active, newest-first. */
40
+ export function emptyRunFilters(): RunsListFilters {
41
+ return { search: "", statuses: [], sort_direction: "desc" };
42
+ }
43
+
44
+ /**
45
+ * Statuses worth an operator's attention: every end reason the retry policy
46
+ * treats as a failure, plus `missed` (a scheduled run the daemon was down
47
+ * for). Backs the one-click preset that serves Prime Directive #1 — nothing
48
+ * silently fails.
49
+ */
50
+ export const NEEDS_ATTENTION_STATUSES: readonly string[] = [...FAILURE_END_REASONS, "missed"];
51
+
52
+ /** True when `statuses` is exactly the needs-attention set (order-insensitive). */
53
+ export function isNeedsAttention(statuses: string[]): boolean {
54
+ if (statuses.length !== NEEDS_ATTENTION_STATUSES.length) return false;
55
+ const set = new Set(statuses);
56
+ return NEEDS_ATTENTION_STATUSES.every((s) => set.has(s));
57
+ }
58
+
59
+ /**
60
+ * Outcome buckets: the 14 individual run statuses collapsed into five
61
+ * plain-language groups, so the common case is a five-item pick instead of a
62
+ * flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
63
+ * one adds/removes its members, and the popover's "Advanced" section still
64
+ * exposes the individual statuses for surgical filters (e.g. only `timeout`).
65
+ *
66
+ * Every status appears in exactly one bucket; `Failed` is the needs-attention
67
+ * set (Prime Directive #1). `dot` mirrors that group's color in
68
+ * RUN_STATUS_CONFIG so the bucket reads at a glance.
69
+ */
70
+ export interface StatusBucket {
71
+ key: string;
72
+ label: string;
73
+ /** Tailwind background class for the bucket's status dot. */
74
+ dot: string;
75
+ statuses: readonly string[];
76
+ }
77
+
78
+ export const STATUS_BUCKETS: readonly StatusBucket[] = [
79
+ { key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
80
+ { key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["success"] },
81
+ {
82
+ key: "failed",
83
+ label: "Failed",
84
+ dot: "bg-danger-surface",
85
+ statuses: NEEDS_ATTENTION_STATUSES,
86
+ },
87
+ {
88
+ key: "skipped",
89
+ label: "Skipped",
90
+ dot: "bg-on-surface-faint",
91
+ statuses: ["skipped", "dst_skipped", "queue_full"],
92
+ },
93
+ {
94
+ key: "stopped",
95
+ label: "Stopped",
96
+ dot: "bg-warning-surface",
97
+ statuses: ["stopped", "daemon_stopped"],
98
+ },
99
+ ];
100
+
101
+ export type BucketState = "on" | "partial" | "off";
102
+
103
+ /** Whether none, some, or all of a bucket's statuses are currently selected. */
104
+ export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
105
+ const set = new Set(statuses);
106
+ const present = bucket.statuses.filter((s) => set.has(s)).length;
107
+ if (present === 0) return "off";
108
+ if (present === bucket.statuses.length) return "on";
109
+ return "partial";
110
+ }
111
+
112
+ /** Toggle a whole bucket: fully selected → clear it; otherwise select all of it. */
113
+ export function toggleBucket(f: RunsListFilters, bucket: StatusBucket): RunsListFilters {
114
+ const members = new Set(bucket.statuses);
115
+ if (bucketState(f.statuses, bucket) === "on") {
116
+ return { ...f, statuses: f.statuses.filter((s) => !members.has(s)) };
117
+ }
118
+ return { ...f, statuses: [...new Set([...f.statuses, ...bucket.statuses])] };
119
+ }
120
+
121
+ /** The bucket the selection matches exactly (all of it, nothing else), if any. */
122
+ function exactBucket(statuses: string[]): StatusBucket | undefined {
123
+ return STATUS_BUCKETS.find(
124
+ (b) => b.statuses.length === statuses.length && bucketState(statuses, b) === "on",
125
+ );
126
+ }
127
+
128
+ /**
129
+ * The popover-managed dimensions, in display (most→least useful) order.
130
+ * `task` only ever applies on the cross-task /runs view — on a single task's
131
+ * page the task name is the page scope (injected at fetch time), never a
132
+ * popover-set filter, so it stays absent from those filters and is not counted.
133
+ */
134
+ export type FilterDimension = "status" | "time" | "task" | "triggered_by" | "exit_code" | "retries";
135
+
136
+ const DIMENSION_ORDER: FilterDimension[] = [
137
+ "status",
138
+ "time",
139
+ "task",
140
+ "triggered_by",
141
+ "exit_code",
142
+ "retries",
143
+ ];
144
+
145
+ /** Whether a single dimension is currently constraining the result set. */
146
+ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
147
+ switch (dim) {
148
+ case "status":
149
+ return f.statuses.length > 0;
150
+ case "time":
151
+ return Boolean(f.created_after) || Boolean(f.created_before);
152
+ case "task":
153
+ return Boolean(f.task_name);
154
+ case "triggered_by":
155
+ return Boolean(f.triggered_by);
156
+ case "exit_code":
157
+ return exitCodeRangeActive(f.exit_code);
158
+ case "retries":
159
+ return Boolean(f.retries_only);
160
+ }
161
+ }
162
+
163
+ /** The active dimensions, in display order — drives the chip row. */
164
+ export function activeDimensions(f: RunsListFilters): FilterDimension[] {
165
+ return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
166
+ }
167
+
168
+ /** How many dimensions are active — the count badge on the Filter button. */
169
+ export function activeFilterCount(f: RunsListFilters): number {
170
+ return activeDimensions(f).length;
171
+ }
172
+
173
+ /** Reset one dimension, leaving the others (and search/sort) untouched. */
174
+ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
175
+ switch (dim) {
176
+ case "status":
177
+ return { ...f, statuses: [] };
178
+ case "time":
179
+ return { ...f, created_after: undefined, created_before: undefined };
180
+ case "task":
181
+ return { ...f, task_name: undefined };
182
+ case "triggered_by":
183
+ return { ...f, triggered_by: undefined };
184
+ case "exit_code":
185
+ return { ...f, exit_code: undefined };
186
+ case "retries":
187
+ return { ...f, retries_only: undefined };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Clear every popover dimension at once, preserving the header search and the
193
+ * sort direction (those are separate toolbar controls, not popover state).
194
+ */
195
+ export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
196
+ return {
197
+ ...f,
198
+ statuses: [],
199
+ created_after: undefined,
200
+ created_before: undefined,
201
+ task_name: undefined,
202
+ triggered_by: undefined,
203
+ exit_code: undefined,
204
+ retries_only: undefined,
205
+ };
206
+ }
207
+
208
+ /** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
209
+ export function humanizeStatus(status: string): string {
210
+ if (!status) return status;
211
+ const spaced = status.replaceAll("_", " ");
212
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
213
+ }
214
+
215
+ /**
216
+ * Descriptive label for a run's trigger source — fuller than the one-word row
217
+ * badge (`formatTriggeredByLabel`), for the filter dropdown and chip where the
218
+ * extra words disambiguate (e.g. "Manual (UI or API)" vs a bare "API").
219
+ *
220
+ * Note `cron` covers both on-time schedule firings and catch-up for missed
221
+ * runs, and `startup` is specifically `run_on_start` (not catch-up).
222
+ */
223
+ export function triggerDescription(trigger: string): string {
224
+ switch (trigger) {
225
+ case "cron":
226
+ return "Scheduled (cron)";
227
+ case "api":
228
+ return "Manual (UI or API)";
229
+ case "cloud":
230
+ return "Control plane";
231
+ case "service":
232
+ return "Service auto-start";
233
+ case "startup":
234
+ return "On daemon start";
235
+ default:
236
+ return humanizeStatus(trigger);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The triggers offered in the filter dropdown. `cloud` is intentionally
242
+ * excluded for now — control-plane runs still carry the `cloud` trigger, but
243
+ * it isn't a selectable filter dimension.
244
+ */
245
+ export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "cloud");
246
+
247
+ /** Chip label for the status dimension — names a whole bucket when it matches. */
248
+ export function statusChipLabel(statuses: string[]): string {
249
+ const bucket = exactBucket(statuses);
250
+ if (bucket) return bucket.label;
251
+ const [only] = statuses;
252
+ if (statuses.length === 1 && only !== undefined) return humanizeStatus(only);
253
+ return `${String(statuses.length)} statuses`;
254
+ }
255
+
256
+ // --- Time helpers --------------------------------------------------------
257
+ //
258
+ // A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
259
+ // instant: the "from" edge is that day's 00:00, the "to" edge is its end
260
+ // (23:59:59.999) so picking the same day for both bounds — or the one-click
261
+ // "on day" shortcut — captures the whole day and round-trips back to the same
262
+ // date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
263
+ // shape the list query and SSE-merge filter already expect.
264
+
265
+ /** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
266
+ function localDay(dateStr: string): Date | null {
267
+ const parts = dateStr.split("-");
268
+ if (parts.length !== 3) return null;
269
+ const [y, m, d] = parts.map(Number);
270
+ if (y === undefined || m === undefined || d === undefined) return null;
271
+ if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;
272
+ const date = new Date(y, m - 1, d);
273
+ return Number.isNaN(date.getTime()) ? null : date;
274
+ }
275
+
276
+ /** `YYYY-MM-DD` → that day's 00:00 local, as an RFC3339 instant. */
277
+ export function dayStartIso(dateStr: string): string | undefined {
278
+ const d = localDay(dateStr);
279
+ return d ? d.toISOString() : undefined;
280
+ }
281
+
282
+ /** `YYYY-MM-DD` → that day's last millisecond local, as an RFC3339 instant. */
283
+ export function dayEndIso(dateStr: string): string | undefined {
284
+ const d = localDay(dateStr);
285
+ if (!d) return undefined;
286
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999).toISOString();
287
+ }
288
+
289
+ /** An RFC3339 instant → the local `YYYY-MM-DD` it falls on (for date inputs). */
290
+ export function isoToDayInput(iso: string): string {
291
+ const d = new Date(iso);
292
+ if (Number.isNaN(d.getTime())) return "";
293
+ const pad = (n: number) => String(n).padStart(2, "0");
294
+ return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
295
+ }
296
+
297
+ /**
298
+ * True when the bounds describe exactly one calendar day (its 00:00 → end of
299
+ * day). Picking the same date for both From and To produces such a range, so
300
+ * the chip can read "On <date>" instead of a from–to pair.
301
+ */
302
+ export function isWholeDay(after?: string, before?: string): boolean {
303
+ if (!after || !before) return false;
304
+ const a = isoToDayInput(after);
305
+ return (
306
+ a !== "" &&
307
+ a === isoToDayInput(before) &&
308
+ new Date(after).getTime() < new Date(before).getTime()
309
+ );
310
+ }
311
+
312
+ // --- Exit-code expression ------------------------------------------------
313
+ //
314
+ // The popover takes a free-form exit-code expression and normalizes it to an
315
+ // inclusive integer [min, max] range — the shape the server gates on. Because
316
+ // exit codes are integers, a strict comparison collapses to an inclusive
317
+ // bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
318
+ // and a bare number is an exact match (min = max = n).
319
+
320
+ export interface ExitCodeRange {
321
+ min?: number;
322
+ max?: number;
323
+ }
324
+
325
+ const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
326
+
327
+ /**
328
+ * Parse an exit-code expression into an inclusive [min, max] range. Returns
329
+ * `valid: false` if any token is unparseable, so the UI can flag a typo
330
+ * instead of silently applying half of it. An empty expression is valid with
331
+ * no bounds (no constraint).
332
+ */
333
+ export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
334
+ const range: ExitCodeRange = {};
335
+ const tokens = expr.trim().split(/\s+/).filter(Boolean);
336
+ for (const tok of tokens) {
337
+ const m = EXIT_CODE_TOKEN.exec(tok);
338
+ if (!m) return { range: {}, valid: false };
339
+ const numStr = m[2];
340
+ if (numStr === undefined) return { range: {}, valid: false };
341
+ const n = Number.parseInt(numStr, 10);
342
+ switch (m[1]) {
343
+ case ">":
344
+ range.min = n + 1;
345
+ break;
346
+ case ">=":
347
+ range.min = n;
348
+ break;
349
+ case "<":
350
+ range.max = n - 1;
351
+ break;
352
+ case "<=":
353
+ range.max = n;
354
+ break;
355
+ default:
356
+ range.min = n;
357
+ range.max = n;
358
+ }
359
+ }
360
+ return { range, valid: true };
361
+ }
362
+
363
+ /** The resolved range for an expression, or an empty (unbounded) range if invalid. */
364
+ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
365
+ if (!expr) return {};
366
+ const { range, valid } = parseExitCodeRange(expr);
367
+ return valid ? range : {};
368
+ }
369
+
370
+ /** True when the expression resolves to at least one bound (a real filter). */
371
+ export function exitCodeRangeActive(expr: string | undefined): boolean {
372
+ const { min, max } = exitCodeRange(expr);
373
+ return min !== undefined || max !== undefined;
374
+ }
375
+
376
+ /** True when the expression is empty or fully parseable — drives input validation. */
377
+ export function isExitCodeExprValid(expr: string): boolean {
378
+ return parseExitCodeRange(expr).valid;
379
+ }
380
+
381
+ /** Chip label for an active exit-code filter, e.g. `Exit >100 <150`. */
382
+ export function exitCodeChipLabel(expr: string | undefined): string {
383
+ return `Exit ${(expr ?? "").trim()}`;
384
+ }
@@ -7,6 +7,7 @@ import {
7
7
  runStartDelay,
8
8
  formatTriggeredByLabel,
9
9
  runRetryLabel,
10
+ instanceSuffix,
10
11
  } from "./run-helpers.js";
11
12
 
12
13
  describe("runDuration", () => {
@@ -39,6 +40,19 @@ describe("runDuration", () => {
39
40
  expect(runDuration({ start_at: "2024-06-15T12:00:00.000Z" })).toBe("2s");
40
41
  });
41
42
  });
43
+
44
+ it("counts against an injected now for an in-progress run", () => {
45
+ const start = "2024-06-15T12:00:00.000Z";
46
+ const now = new Date("2024-06-15T12:00:07.000Z").getTime();
47
+ expect(runDuration({ start_at: start }, now)).toBe("7s");
48
+ });
49
+
50
+ it("ignores the injected now once the run has ended", () => {
51
+ const start = "2024-06-15T12:00:00.000Z";
52
+ const end = "2024-06-15T12:00:05.000Z";
53
+ const now = new Date("2024-06-15T12:01:00.000Z").getTime();
54
+ expect(runDuration({ start_at: start, end_at: end }, now)).toBe("5s");
55
+ });
42
56
  });
43
57
 
44
58
  describe("runStartDelay", () => {
@@ -88,3 +102,22 @@ describe("runRetryLabel", () => {
88
102
  expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
89
103
  });
90
104
  });
105
+
106
+ describe("instanceSuffix", () => {
107
+ it("returns no suffix for a single-instance task", () => {
108
+ expect(instanceSuffix(0, 1)).toBe("");
109
+ });
110
+
111
+ it("returns no suffix for a non-service (count 0)", () => {
112
+ expect(instanceSuffix(0, 0)).toBe("");
113
+ });
114
+
115
+ it("suffixes slot 0 of a multi-instance service as #1 (1-based)", () => {
116
+ expect(instanceSuffix(0, 3)).toBe("#1");
117
+ });
118
+
119
+ it("maps the stored 0-based slot to a 1-based suffix", () => {
120
+ expect(instanceSuffix(1, 3)).toBe("#2");
121
+ expect(instanceSuffix(2, 3)).toBe("#3");
122
+ });
123
+ });
@@ -4,10 +4,13 @@
4
4
  import type { Run } from "@runwisp/common";
5
5
  import { formatDuration } from "../../utils/format.js";
6
6
 
7
- export function runDuration(run: Pick<Run, "start_at" | "end_at">): string | undefined {
7
+ export function runDuration(
8
+ run: Pick<Run, "start_at" | "end_at">,
9
+ now: number = Date.now(),
10
+ ): string | undefined {
8
11
  if (!run.start_at) return undefined;
9
12
  const start = new Date(run.start_at).getTime();
10
- const end = run.end_at ? new Date(run.end_at).getTime() : Date.now();
13
+ const end = run.end_at ? new Date(run.end_at).getTime() : now;
11
14
  return formatDuration(end - start);
12
15
  }
13
16
 
@@ -26,6 +29,21 @@ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string
26
29
  return formatDuration(delay);
27
30
  }
28
31
 
32
+ /**
33
+ * Display suffix for a run's instance slot. A service configured with more than
34
+ * one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
35
+ * a single-instance task (or any non-service, where `instanceCount` is 1)
36
+ * returns an empty string so the bare task name is shown. `instanceIndex` is
37
+ * the stored 0-based slot; `instanceCount` is the task's currently configured
38
+ * instance count.
39
+ */
40
+ export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
41
+ if (instanceCount > 1) {
42
+ return `#${String(instanceIndex + 1)}`;
43
+ }
44
+ return "";
45
+ }
46
+
29
47
  /** Human label for why a run fired (the `triggered_by` source). */
30
48
  export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
31
49
  if (triggeredBy === "api") return "API";
package/src/lib/index.ts CHANGED
@@ -80,10 +80,25 @@ export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
80
80
 
81
81
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
82
82
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
83
- export type {
84
- RunsListFilters,
85
- RunsListSortDirection,
86
- } from "./components/dashboard/RunsList.svelte";
83
+ export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
84
+ export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
85
+ export {
86
+ emptyRunFilters,
87
+ NEEDS_ATTENTION_STATUSES,
88
+ isNeedsAttention,
89
+ activeFilterCount,
90
+ activeDimensions,
91
+ dimensionActive,
92
+ clearDimension,
93
+ clearPopoverFilters,
94
+ humanizeStatus,
95
+ statusChipLabel,
96
+ exitCodeRange,
97
+ type ExitCodeRange,
98
+ type RunsListFilters,
99
+ type RunsListSortDirection,
100
+ type FilterDimension,
101
+ } from "./components/dashboard/run-filters.js";
87
102
  export {
88
103
  getRunStatusConfig,
89
104
  runDisplayStatus,
@@ -96,6 +111,7 @@ export {
96
111
  runPhaseOrder,
97
112
  formatTriggeredByLabel,
98
113
  runRetryLabel,
114
+ instanceSuffix,
99
115
  } from "./components/dashboard/run-helpers.js";
100
116
  export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
101
117
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
@@ -111,3 +127,4 @@ export {
111
127
  formatFullDateTime,
112
128
  } from "./utils/format.js";
113
129
  export { formatShortId } from "./utils/id.js";
130
+ export { TickingNow } from "./utils/ticking-now.svelte.js";
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { LogSlice, LogEvent } from "./types.js";
4
+ import type { LogSlice, LogEvent, RegionUpdate } from "./types.js";
5
5
  import { SvelteMap } from "svelte/reactivity";
6
6
  import { visibleColumns } from "./ansi.js";
7
7
 
@@ -12,8 +12,21 @@ import { visibleColumns } from "./ansi.js";
12
12
  * the cache must aggressively prune data that is far from the current viewport.
13
13
  * It uses a SvelteMap for reactive, efficient line lookups and updates.
14
14
  */
15
+ // Streams pinned to the front of the overlay, in render order. The companion
16
+ // Set backs the membership test in `overlayRows`. Both are module-level,
17
+ // immutable, and non-reactive — a fixed lookup table, not component state.
18
+ const PREFERRED_STREAM_ORDER = ["stdout", "stderr"] as const;
19
+ const PREFERRED_STREAMS: ReadonlySet<string> = new Set(PREFERRED_STREAM_ORDER);
20
+
15
21
  export class LogCache {
16
22
  lines = $state(new SvelteMap<number, string>());
23
+ // Live-region overlay, keyed by stream (stdout/stderr). Each stream has at
24
+ // most one active region; the frame is replaced wholesale on each update and
25
+ // never persisted. Rendered below the committed lines as an in-place tail.
26
+ regions = $state(new SvelteMap<string, { epoch: number; rows: string[] }>());
27
+ // Per-line frame-history counts. A non-zero entry marks a settled progress
28
+ // bar / multi-line redraw whose prior frames can be fetched on click.
29
+ frameCounts = $state(new SvelteMap<number, number>());
17
30
  totalLines = $state(0);
18
31
  totalBytes = $state(0);
19
32
  finished = $state(false);
@@ -50,13 +63,52 @@ export class LogCache {
50
63
  return { touched, min, max };
51
64
  }
52
65
 
66
+ // Flattened overlay rows for rendering, stdout before stderr (then any
67
+ // other streams, sorted) for stable ordering. Reactive: reads the regions
68
+ // map so it recomputes on update.
69
+ get overlayRows(): string[] {
70
+ const rest = [...this.regions.keys()]
71
+ .filter((s) => !PREFERRED_STREAMS.has(s))
72
+ .sort((a, b) => a.localeCompare(b));
73
+ const out: string[] = [];
74
+ for (const stream of [...PREFERRED_STREAM_ORDER, ...rest]) {
75
+ const region = this.regions.get(stream);
76
+ if (region) out.push(...region.rows);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ applyRegion(update: RegionUpdate) {
82
+ if (update.rows.length === 0) {
83
+ this.regions.delete(update.stream);
84
+ return;
85
+ }
86
+ this.regions.set(update.stream, { epoch: update.epoch, rows: update.rows });
87
+ for (const row of update.rows) {
88
+ this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(row));
89
+ }
90
+ }
91
+
53
92
  applyEvent(event: LogEvent) {
93
+ if (event.region) {
94
+ this.applyRegion(event.region);
95
+ return { touched: false, min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY };
96
+ }
54
97
  this.totalLines = Math.max(this.totalLines, Math.floor(event.sizeLines));
55
98
  this.totalBytes = Math.max(this.totalBytes, Math.floor(event.sizeBytes ?? 0));
56
99
  this.finished = event.finished;
100
+ // A finished run has no live region; drop any lingering overlay so a
101
+ // dropped clear-frame can't leave a stale tail painted forever.
102
+ if (event.finished && this.regions.size > 0) this.regions.clear();
57
103
  if (event.firstAvailableLine !== undefined && event.firstAvailableLine > 0) {
58
104
  this.firstAvailableLine = Math.max(this.firstAvailableLine, event.firstAvailableLine);
59
105
  }
106
+ if (event.frameCounts) {
107
+ for (const [k, v] of Object.entries(event.frameCounts)) {
108
+ const line = Number(k);
109
+ if (Number.isFinite(line) && line >= 0 && v > 0) this.frameCounts.set(line, v);
110
+ }
111
+ }
60
112
 
61
113
  return this.mergeSlice(event.lines);
62
114
  }
@@ -78,6 +130,8 @@ export class LogCache {
78
130
 
79
131
  reset() {
80
132
  this.lines.clear();
133
+ this.regions.clear();
134
+ this.frameCounts.clear();
81
135
  this.totalLines = 0;
82
136
  this.totalBytes = 0;
83
137
  this.finished = false;
@@ -3,12 +3,29 @@
3
3
 
4
4
  export type LogSlice = Record<number, string>;
5
5
 
6
+ /**
7
+ * RegionUpdate is a live snapshot of a still-animating output region (a `\r`
8
+ * progress bar or a multi-line ANSI redraw). It is never persisted: the cache
9
+ * holds it in a transient overlay keyed by stream, replacing the whole frame on
10
+ * each update. Empty `rows` clears the overlay for that stream.
11
+ */
12
+ export type RegionUpdate = {
13
+ stream: string;
14
+ epoch: number;
15
+ rows: string[];
16
+ };
17
+
6
18
  export type LogEvent = {
7
19
  lines: LogSlice;
8
20
  sizeLines: number;
9
21
  sizeBytes?: number;
10
22
  finished: boolean;
11
23
  firstAvailableLine?: number;
24
+ region?: RegionUpdate;
25
+ // Per-line count of recorded prior frames, keyed by line number. Present only
26
+ // for settled progress-bar / multi-line-redraw anchor lines, marking them as
27
+ // clickable to rewind through earlier states. Plain lines are absent.
28
+ frameCounts?: Record<number, number>;
12
29
  };
13
30
 
14
31
  export function isLogEvent(value: unknown): value is LogEvent {