@runwisp/ui 1.5.1 → 1.7.1

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 (56) 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/LogCache.svelte.test.ts +135 -0
  43. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  44. package/src/lib/log-console/LogFetcher.svelte.test.ts +159 -0
  45. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  46. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  47. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  48. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  49. package/src/lib/utils/format.test.ts +122 -0
  50. package/src/lib/utils/format.ts +97 -4
  51. package/src/lib/utils/toast.svelte.test.ts +46 -0
  52. package/src/lib/utils/typing-target.ts +10 -0
  53. package/src/stories/Accordion.stories.svelte +60 -1
  54. package/src/stories/CodeBlock.stories.svelte +13 -0
  55. package/src/stories/Link.stories.svelte +25 -1
  56. package/src/stories/Tabs.stories.svelte +17 -0
@@ -23,7 +23,14 @@ interface RunStatusConfig {
23
23
  color: string;
24
24
  bg: string;
25
25
  dot: string;
26
+ /** `dot` without the running pulse, for static markers (spines, filter swatches). */
27
+ solidDot: string;
26
28
  badge: string;
29
+ /**
30
+ * Accent colour for statuses that mean "something to triage", or undefined.
31
+ * A deliberate stop or a skip is not an alarm, and success least of all.
32
+ */
33
+ alarm: string | undefined;
27
34
  /** One-sentence explanation of what this status means, for tooltips. */
28
35
  description: string;
29
36
  }
@@ -34,19 +41,25 @@ const DANGER = {
34
41
  color: "text-danger-surface",
35
42
  bg: "bg-danger-soft",
36
43
  dot: "bg-danger-surface",
44
+ solidDot: "bg-danger-surface",
37
45
  badge: "bg-danger-soft text-danger-soft-text",
46
+ alarm: "var(--color-danger-surface)",
38
47
  };
39
48
  const WARNING = {
40
49
  color: "text-warning-surface",
41
50
  bg: "bg-warning-soft",
42
51
  dot: "bg-warning-surface",
52
+ solidDot: "bg-warning-surface",
43
53
  badge: "bg-warning-soft text-warning-soft-text",
54
+ alarm: "var(--color-warning-surface)",
44
55
  };
45
56
  const NEUTRAL = {
46
57
  color: "text-on-surface-muted",
47
58
  bg: "bg-surface-sunken",
48
59
  dot: "bg-on-surface-faint",
60
+ solidDot: "bg-on-surface-faint",
49
61
  badge: "bg-surface-sunken text-on-surface",
62
+ alarm: undefined,
50
63
  };
51
64
 
52
65
  export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
@@ -55,7 +68,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
55
68
  color: "text-info-surface",
56
69
  bg: "bg-info-soft",
57
70
  dot: "bg-info-surface animate-pulse",
71
+ solidDot: "bg-info-surface",
58
72
  badge: "bg-info-soft text-info-soft-text",
73
+ alarm: undefined,
59
74
  description: "This run is executing right now.",
60
75
  },
61
76
  succeeded: {
@@ -63,7 +78,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
63
78
  color: "text-success-surface",
64
79
  bg: "bg-success-soft",
65
80
  dot: "bg-success-surface",
81
+ solidDot: "bg-success-surface",
66
82
  badge: "bg-success-soft text-success-soft-text",
83
+ alarm: undefined,
67
84
  description: "The run finished with exit code 0 - everything OK.",
68
85
  },
69
86
  failed: {
@@ -79,6 +96,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
79
96
  },
80
97
  stopped: {
81
98
  ...WARNING,
99
+ alarm: undefined,
82
100
  icon: CircleStop,
83
101
  description: "A human or an external script manually stopped this run before it finished.",
84
102
  },
@@ -7,3 +7,9 @@ export interface DaemonStats {
7
7
  activeTasks: number;
8
8
  successRate: number;
9
9
  }
10
+
11
+ /** A single output-search hit surfaced under its run in the history rail. */
12
+ export interface RunOutputMatch {
13
+ line: number;
14
+ text: string;
15
+ }
package/src/lib/index.ts CHANGED
@@ -24,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
24
24
  export { default as Input } from "./components/Input.svelte";
25
25
  export { default as Radio } from "./components/Radio.svelte";
26
26
  export { default as RadioGroup } from "./components/RadioGroup.svelte";
27
- export { default as Select } from "./components/Select.svelte";
27
+ export { default as Select, type SelectOption } from "./components/Select.svelte";
28
28
  export { default as Textarea } from "./components/Textarea.svelte";
29
29
  export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
30
30
  export { default as Toggle } from "./components/Toggle.svelte";
@@ -86,14 +86,16 @@ export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
86
86
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
87
87
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
88
88
  export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
89
- export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
89
+ export type { RunOutputMatch } from "./components/dashboard/types.js";
90
90
  export {
91
91
  activeFilterCount,
92
92
  emptyRunFilters,
93
93
  FAILURE_STATUS_TOKEN,
94
94
  humanizeStatus,
95
95
  exitCodeRange,
96
+ runFilterParams,
96
97
  type ExitCodeRange,
98
+ type RunFilterParams,
97
99
  type RunsListFilters,
98
100
  } from "./components/dashboard/run-filters.js";
99
101
  export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
@@ -102,6 +104,10 @@ export {
102
104
  runPhaseOrder,
103
105
  formatTriggeredByLabel,
104
106
  instanceSuffix,
107
+ runUsageLabel,
108
+ runUsageScale,
109
+ usageLevel,
110
+ type RunUsageScale,
105
111
  } from "./components/dashboard/run-helpers.js";
106
112
  export { humanizeCron } from "./components/dashboard/cron-format.js";
107
113
  export type { DaemonStats } from "./components/dashboard/types.js";
@@ -110,14 +116,19 @@ export { toast } from "./utils/toast.svelte.js";
110
116
  export { extractErrorMessage } from "./utils/error.js";
111
117
  export {
112
118
  formatBytes,
119
+ formatDayMonth,
120
+ formatRelativeCompact,
113
121
  formatRelativeTime,
114
122
  formatRelativeTimeWithAbsolute,
123
+ type RelativeTimeOptions,
115
124
  formatDuration,
116
125
  formatFullDateTime,
117
126
  } from "./utils/format.js";
118
127
  export { formatShortId } from "./utils/id.js";
119
128
  export { debounce } from "./utils/debounce.js";
129
+ export { isTypingTarget } from "./utils/typing-target.js";
120
130
  export { TickingNow } from "./utils/ticking-now.svelte.js";
121
131
  export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
122
132
  export { RunMotion } from "./utils/run-motion.js";
123
133
  export { arrival, leave, shift } from "./actions/row-motion.js";
134
+ export { foldToFit, foldUntilFits, type FoldToFitOptions } from "./actions/fold-to-fit.js";
@@ -0,0 +1,12 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { ansiLineToHtml } from "./ansi.js";
6
+
7
+ let { text }: { text: string } = $props();
8
+ </script>
9
+
10
+ <!-- ansiLineToHtml sanitises its input, which is what makes @html safe here. -->
11
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
12
+ <span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
@@ -0,0 +1,57 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import AnsiLine from "./AnsiLine.svelte";
6
+ import { FRAME_BLOCK_PAD, FRAME_GAP, type FrameHistory } from "./frame-history.svelte.js";
7
+ import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
8
+
9
+ let {
10
+ history,
11
+ top,
12
+ gutterWidth,
13
+ }: {
14
+ history: FrameHistory;
15
+ /** Distance from the top of the virtual surface, px. */
16
+ top: number;
17
+ gutterWidth: number;
18
+ } = $props();
19
+ </script>
20
+
21
+ <div
22
+ class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
23
+ style="top: {top}px; height: {history.blockHeight}px;"
24
+ >
25
+ <div style="padding: {FRAME_BLOCK_PAD}px 0;">
26
+ {#if history.frames}
27
+ {#each history.frames as frame, fi (fi)}
28
+ <div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
29
+ <div
30
+ class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
31
+ style="height: {LINE_HEIGHT}px; line-height: {LINE_HEIGHT}px;"
32
+ >
33
+ Frame {fi + 1} of {history.frames.length}
34
+ </div>
35
+ {#each frame as row, ri (ri)}
36
+ <div class="flex items-center" style="height: {LINE_HEIGHT}px;">
37
+ <div
38
+ class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
39
+ style="width: {gutterWidth}px;"
40
+ ></div>
41
+ <div class="flex-1 pr-4 text-[var(--rw-con-text)]">
42
+ <AnsiLine text={row} />
43
+ </div>
44
+ </div>
45
+ {/each}
46
+ </div>
47
+ {/each}
48
+ {:else}
49
+ <div
50
+ class="px-3 text-xs text-[var(--rw-con-dim)] italic"
51
+ style="line-height: {LINE_HEIGHT}px; padding-left: {gutterWidth}px;"
52
+ >
53
+ {history.failed ? "Failed to load frame history" : "Loading frames…"}
54
+ </div>
55
+ {/if}
56
+ </div>
57
+ </div>
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+
7
+ describe("LogCache", () => {
8
+ it("merges a slice, skipping bad keys and reporting what changed", () => {
9
+ const cache = new LogCache();
10
+ expect(cache.mergeSlice({ 2: "two", 5: "five", [-1]: "neg" })).toEqual({
11
+ touched: true,
12
+ min: 2,
13
+ max: 5,
14
+ });
15
+ expect(cache.size).toBe(2);
16
+ expect(cache.mergeSlice({ 2: "two" }).touched).toBe(false);
17
+ });
18
+
19
+ it("keeps the widest line width even after that line is gone", () => {
20
+ const cache = new LogCache();
21
+ cache.mergeSlice({ 0: "x".repeat(40) });
22
+ cache.mergeSlice({ 0: "short" });
23
+ expect(cache.maxLineColumns).toBe(40);
24
+ });
25
+
26
+ it("only grows totals and the first available line from events", () => {
27
+ const cache = new LogCache();
28
+ cache.applyEvent({
29
+ lines: {},
30
+ sizeLines: 10,
31
+ sizeBytes: 300,
32
+ finished: false,
33
+ firstAvailableLine: 4,
34
+ });
35
+ cache.applyEvent({
36
+ lines: {},
37
+ sizeLines: 8,
38
+ sizeBytes: 100,
39
+ finished: false,
40
+ firstAvailableLine: 2,
41
+ });
42
+ expect([cache.totalLines, cache.totalBytes, cache.firstAvailableLine]).toEqual([
43
+ 10, 300, 4,
44
+ ]);
45
+ });
46
+
47
+ it("records frame history only for lines that have some", () => {
48
+ const cache = new LogCache();
49
+ cache.applyEvent({
50
+ lines: { 3: "100%" },
51
+ sizeLines: 4,
52
+ finished: false,
53
+ frameCounts: { 3: 12, 1: 0 },
54
+ });
55
+ expect([...cache.frameCounts]).toEqual([[3, 12]]);
56
+ });
57
+
58
+ it("orders the live overlay stdout, stderr, then other streams by name", () => {
59
+ const cache = new LogCache();
60
+ cache.applyRegion({ stream: "zeta", epoch: 1, rows: ["z"] });
61
+ cache.applyRegion({ stream: "stderr", epoch: 1, rows: ["e"] });
62
+ cache.applyRegion({ stream: "alpha", epoch: 1, rows: ["a"] });
63
+ cache.applyRegion({ stream: "stdout", epoch: 1, rows: ["o1", "o2"] });
64
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a", "z"]);
65
+
66
+ cache.applyRegion({ stream: "zeta", epoch: 2, rows: [] });
67
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a"]);
68
+ });
69
+
70
+ it("keeps region frames out of the committed lines and drops them once finished", () => {
71
+ const cache = new LogCache();
72
+ const res = cache.applyEvent({
73
+ lines: {},
74
+ sizeLines: 0,
75
+ finished: false,
76
+ region: { stream: "stdout", epoch: 1, rows: ["[##..] 50%"] },
77
+ });
78
+ expect(res.touched).toBe(false);
79
+ expect(cache.size).toBe(0);
80
+ expect(cache.overlayRows).toEqual(["[##..] 50%"]);
81
+
82
+ cache.applyEvent({ lines: { 0: "done" }, sizeLines: 1, finished: true });
83
+ expect(cache.overlayRows).toEqual([]);
84
+ expect(cache.finished).toBe(true);
85
+ });
86
+
87
+ it("prunes lines far from the viewport once the cache is full", () => {
88
+ const cache = new LogCache();
89
+ const slice: Record<number, string> = {};
90
+ for (let n = 0; n < cache.MAX_CACHE_SIZE; n++) slice[n] = "l";
91
+ cache.mergeSlice(slice);
92
+
93
+ cache.prune(40_000, 40_100);
94
+ expect(cache.lines.has(40_050)).toBe(true);
95
+ expect(cache.lines.has(0)).toBe(false);
96
+ expect(cache.size).toBeLessThan(cache.MAX_CACHE_SIZE);
97
+ });
98
+
99
+ it("leaves a cache under the limit alone", () => {
100
+ const cache = new LogCache();
101
+ cache.mergeSlice({ 0: "a", 99_999: "b" });
102
+ cache.prune(50_000, 50_010);
103
+ expect(cache.size).toBe(2);
104
+ });
105
+
106
+ it("judges a range complete within what the server still holds", () => {
107
+ const cache = new LogCache();
108
+ cache.applyEvent({
109
+ lines: { 5: "a", 6: "b" },
110
+ sizeLines: 7,
111
+ finished: false,
112
+ firstAvailableLine: 5,
113
+ });
114
+ expect(cache.isRangeComplete(0, 100)).toBe(true);
115
+ cache.applyEvent({ lines: {}, sizeLines: 8, finished: false });
116
+ expect(cache.isRangeComplete(0, 100)).toBe(false);
117
+ });
118
+
119
+ it("resets to empty", () => {
120
+ const cache = new LogCache();
121
+ cache.applyEvent({
122
+ lines: { 0: "a" },
123
+ sizeLines: 1,
124
+ finished: true,
125
+ firstAvailableLine: 1,
126
+ });
127
+ cache.reset();
128
+ expect([cache.size, cache.totalLines, cache.finished, cache.firstAvailableLine]).toEqual([
129
+ 0,
130
+ 0,
131
+ false,
132
+ 0,
133
+ ]);
134
+ });
135
+ });
@@ -0,0 +1,88 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { formatBytes } from "../utils/format.js";
6
+
7
+ let {
8
+ followTail,
9
+ streaming,
10
+ finished,
11
+ fetching,
12
+ totalLines,
13
+ totalBytes,
14
+ onFollow,
15
+ }: {
16
+ followTail: boolean;
17
+ streaming: boolean;
18
+ finished: boolean;
19
+ fetching: boolean;
20
+ totalLines: number;
21
+ totalBytes: number;
22
+ onFollow: () => void;
23
+ } = $props();
24
+ </script>
25
+
26
+ {#if !followTail && totalLines > 0}
27
+ <button
28
+ class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-[3px] border
29
+ border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]
30
+ px-3 py-1.5 text-xs
31
+ text-[var(--rw-con-text)]
32
+ hover:border-term-teal hover:text-term-teal active:translate-y-[1px]"
33
+ onclick={onFollow}
34
+ >
35
+ <svg
36
+ class="h-3.5 w-3.5"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ stroke-width="2"
41
+ >
42
+ <path d="M12 5v14M19 12l-7 7-7-7" />
43
+ </svg>
44
+ Scroll to bottom
45
+ </button>
46
+ {/if}
47
+
48
+ <div
49
+ class="flex flex-shrink-0 items-center justify-between border-t border-[var(--rw-con-gutter)]
50
+ bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] tracking-wide text-[var(--rw-con-dim)]"
51
+ >
52
+ <div class="flex items-center gap-3">
53
+ {#if streaming}
54
+ <div class="flex items-center gap-1.5 text-aurora-400">
55
+ <div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
56
+ Streaming
57
+ </div>
58
+ {:else if finished}
59
+ <span>Stream ended</span>
60
+ {/if}
61
+ {#if fetching}
62
+ <span>Fetching...</span>
63
+ {/if}
64
+ </div>
65
+ <div class="flex items-center gap-4">
66
+ <span>{totalLines.toLocaleString()} lines</span>
67
+ {#if totalBytes > 0}
68
+ <span>{formatBytes(totalBytes)}</span>
69
+ {/if}
70
+ {#if followTail}
71
+ <span class="inline-flex items-center gap-1.5 text-term-ok">
72
+ <svg
73
+ class="h-3 w-3"
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ stroke-width="2.2"
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ >
81
+ <line x1="12" y1="5" x2="12" y2="19" />
82
+ <polyline points="6 13 12 19 18 13" />
83
+ </svg>
84
+ Auto-scroll
85
+ </span>
86
+ {/if}
87
+ </div>
88
+ </div>
@@ -0,0 +1,159 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+ import { LogFetcher } from "./LogFetcher.svelte";
7
+ import type { LogEvent, LogSlice } from "./types";
8
+
9
+ const CHUNK = 4096;
10
+
11
+ /** A cache that knows the log is `total` lines long but holds none of them. */
12
+ function emptyCache(total: number): LogCache {
13
+ const cache = new LogCache();
14
+ cache.applyEvent({ lines: {}, sizeLines: total, finished: false });
15
+ return cache;
16
+ }
17
+
18
+ /** A fetch whose calls stay pending until the test settles them. */
19
+ function deferredFetch() {
20
+ const pending: { from: number; to: number; settle: (v: LogSlice | LogEvent) => void }[] = [];
21
+ const fn = vi.fn(
22
+ (from: number, to: number) =>
23
+ new Promise<LogSlice | LogEvent>((resolve) => {
24
+ pending.push({ from, to, settle: resolve });
25
+ }),
26
+ );
27
+ return { fn, pending };
28
+ }
29
+
30
+ const live: LogFetcher[] = [];
31
+
32
+ function start(...args: ConstructorParameters<typeof LogFetcher>): LogFetcher {
33
+ const fetcher = new LogFetcher(...args);
34
+ live.push(fetcher);
35
+ return fetcher;
36
+ }
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers();
40
+ });
41
+
42
+ afterEach(() => {
43
+ for (const fetcher of live.splice(0)) fetcher.destroy();
44
+ vi.useRealTimers();
45
+ });
46
+
47
+ describe("LogFetcher", () => {
48
+ it("fetches a missing chunk once, merges it, and reports the loaded range", async () => {
49
+ const cache = emptyCache(10);
50
+ const onLoaded = vi.fn();
51
+ const fetchLogs = vi.fn(() => ({ 0: "a", 9: "j" }));
52
+ const fetcher = start(cache, fetchLogs, onLoaded);
53
+
54
+ fetcher.enqueue(0, 9);
55
+ fetcher.enqueue(2, 5); // same chunk, already queued
56
+ await vi.runAllTimersAsync();
57
+
58
+ expect(fetchLogs).toHaveBeenCalledExactlyOnceWith(0, 9);
59
+ expect(cache.lines.get(9)).toBe("j");
60
+ expect(onLoaded).toHaveBeenCalledWith(0, 9);
61
+ expect(fetcher.isFetching).toBe(false);
62
+ });
63
+
64
+ it("applies an event response, not just a slice", async () => {
65
+ const cache = emptyCache(1);
66
+ const fetcher = start(cache, () => ({ lines: { 0: "x" }, sizeLines: 3, finished: true }));
67
+ fetcher.enqueue(0, 0);
68
+ await vi.runAllTimersAsync();
69
+ expect([cache.totalLines, cache.finished]).toEqual([3, true]);
70
+ });
71
+
72
+ it("skips a range the cache already holds", async () => {
73
+ const cache = emptyCache(2);
74
+ cache.mergeSlice({ 0: "a", 1: "b" });
75
+ const fetchLogs = vi.fn(() => ({}));
76
+ const fetcher = start(cache, fetchLogs);
77
+ fetcher.enqueue(0, 1);
78
+ await vi.runAllTimersAsync();
79
+ expect(fetchLogs).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("never asks for lines rotated away", async () => {
83
+ const cache = emptyCache(CHUNK * 2);
84
+ cache.applyEvent({
85
+ lines: {},
86
+ sizeLines: CHUNK * 2,
87
+ finished: false,
88
+ firstAvailableLine: CHUNK + 5,
89
+ });
90
+ const fetchLogs = vi.fn(() => ({}));
91
+ const fetcher = start(cache, fetchLogs);
92
+ fetcher.enqueue(0, CHUNK * 2 - 1);
93
+ await vi.runAllTimersAsync();
94
+ expect(fetchLogs.mock.calls).toEqual([[CHUNK, CHUNK * 2 - 1]]);
95
+ });
96
+
97
+ it("keeps at most two chunks in flight", async () => {
98
+ const { fn, pending } = deferredFetch();
99
+ const fetcher = start(emptyCache(CHUNK * 3), fn);
100
+
101
+ fetcher.enqueue(0, 0);
102
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS);
103
+ fetcher.enqueue(CHUNK, CHUNK * 3 - 1);
104
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
105
+ expect(fn).toHaveBeenCalledTimes(2);
106
+ expect(fetcher.isFetching).toBe(true);
107
+
108
+ pending[0]?.settle({});
109
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
110
+ expect(fn).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("drops queued chunks the viewport has left behind", async () => {
114
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
115
+ const fetcher = start(emptyCache(CHUNK * 10), fetchLogs);
116
+ fetcher.enqueue(0, CHUNK * 10 - 1);
117
+ fetcher.pruneQueue(CHUNK * 9, CHUNK * 10 - 1);
118
+ await vi.runAllTimersAsync();
119
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([7, 8, 9]);
120
+ });
121
+
122
+ it("recovers from a failed fetch so the chunk can be asked for again", async () => {
123
+ const fetchLogs = vi
124
+ .fn()
125
+ .mockRejectedValueOnce(new Error("503"))
126
+ .mockResolvedValue({ 0: "a" });
127
+ const cache = emptyCache(1);
128
+ const fetcher = start(cache, fetchLogs);
129
+ fetcher.enqueue(0, 0);
130
+ await vi.runAllTimersAsync();
131
+ expect(fetcher.isFetching).toBe(false);
132
+ fetcher.enqueue(0, 0);
133
+ await vi.runAllTimersAsync();
134
+ expect(cache.lines.get(0)).toBe("a");
135
+ });
136
+
137
+ it("requests only the incomplete chunks in view, up to the cap", async () => {
138
+ const cache = emptyCache(CHUNK * 4);
139
+ const filled: Record<number, string> = {};
140
+ for (let n = CHUNK; n < CHUNK * 2; n++) filled[n] = "x";
141
+ cache.mergeSlice(filled);
142
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
143
+ const fetcher = start(cache, fetchLogs);
144
+
145
+ fetcher.maybeRequestMissing(0, CHUNK * 4 - 1, 3);
146
+ await vi.runAllTimersAsync();
147
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([0, 2]);
148
+ });
149
+
150
+ it("does nothing without a fetch function or after destroy", async () => {
151
+ const fetchLogs = vi.fn(() => ({}));
152
+ new LogFetcher(emptyCache(5), undefined).enqueue(0, 4);
153
+ const fetcher = start(emptyCache(5), fetchLogs);
154
+ fetcher.enqueue(0, 4);
155
+ fetcher.destroy();
156
+ await vi.runAllTimersAsync();
157
+ expect(fetchLogs).not.toHaveBeenCalled();
158
+ });
159
+ });
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { FrameHistory } from "./frame-history.svelte.js";
6
+ import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
7
+
8
+ describe("FrameHistory", () => {
9
+ it("takes no space while closed", () => {
10
+ const history = new FrameHistory(() => undefined);
11
+ expect(history.blockHeight).toBe(0);
12
+ });
13
+
14
+ it("reserves a placeholder while frames load, then sizes to them", async () => {
15
+ const history = new FrameHistory(() => () => Promise.resolve([["a", "b"], ["c"]]));
16
+ const done = history.toggle(4);
17
+ expect(history.line).toBe(4);
18
+ expect(history.blockHeight).toBe(LINE_HEIGHT * 2);
19
+ await done;
20
+ // padding 16 + (label + 2 rows) + (label + 1 row) + one 6px gap.
21
+ expect(history.blockHeight).toBe(16 + LINE_HEIGHT * 5 + 6);
22
+ });
23
+
24
+ it("collapses when the open line is toggled again", async () => {
25
+ const history = new FrameHistory(() => () => Promise.resolve([["a"]]));
26
+ await history.toggle(1);
27
+ await history.toggle(1);
28
+ expect(history.line).toBeNull();
29
+ expect(history.frames).toBeNull();
30
+ });
31
+
32
+ it("flags a failed fetch and ignores a superseded one", async () => {
33
+ const failing = new FrameHistory(() => () => Promise.reject(new Error("boom")));
34
+ await failing.toggle(2);
35
+ expect(failing.failed).toBe(true);
36
+
37
+ let release: (frames: string[][]) => void = () => {};
38
+ const slow = new FrameHistory(
39
+ () => () => new Promise<string[][]>((resolve) => (release = resolve)),
40
+ );
41
+ const first = slow.toggle(1);
42
+ slow.collapse();
43
+ release([["late"]]);
44
+ await first;
45
+ expect(slow.frames).toBeNull();
46
+ });
47
+ });