@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.
- package/package.json +5 -7
- 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 +3 -24
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +2 -6
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +95 -418
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -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 +130 -717
- 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 +4 -5
- 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 +265 -543
- 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 +9 -0
- package/src/lib/components/dashboard/cron-format.ts +5 -1
- package/src/lib/components/dashboard/run-filters.test.ts +60 -0
- package/src/lib/components/dashboard/run-filters.ts +78 -6
- package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
- package/src/lib/components/dashboard/run-helpers.ts +122 -6
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- 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/LogConsoleFooter.svelte +88 -0
- 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/utils/format.test.ts +122 -0
- package/src/lib/utils/format.ts +97 -4
- 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/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
|
},
|
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/
|
|
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,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,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
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
5
|
+
|
|
6
|
+
type FetchLineHistory = (lineNum: number) => Promise<string[][]>;
|
|
7
|
+
|
|
8
|
+
/** Padding above and below the block content, px. */
|
|
9
|
+
export const FRAME_BLOCK_PAD = 8;
|
|
10
|
+
/** Gap between consecutive frames, px. */
|
|
11
|
+
export const FRAME_GAP = 6;
|
|
12
|
+
// The block scrolls internally beyond this height, px.
|
|
13
|
+
const FRAME_BLOCK_MAX = 400;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline expansion of a settled progress bar / multi-line redraw: the prior
|
|
17
|
+
* whole-region frames it passed through, shown under its committed line. One
|
|
18
|
+
* expansion at a time.
|
|
19
|
+
*/
|
|
20
|
+
export class FrameHistory {
|
|
21
|
+
/** Absolute line number whose history block is open. */
|
|
22
|
+
line = $state<number | null>(null);
|
|
23
|
+
/** The fetched frames; null while loading. */
|
|
24
|
+
frames = $state<string[][] | null>(null);
|
|
25
|
+
failed = $state(false);
|
|
26
|
+
readonly #fetch: () => FetchLineHistory | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(fetch: () => FetchLineHistory | undefined) {
|
|
29
|
+
this.#fetch = fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Height the open block occupies in the virtual surface. Later lines are
|
|
34
|
+
* shifted down by exactly this, so the layout math stays a single offset.
|
|
35
|
+
*/
|
|
36
|
+
readonly blockHeight = $derived.by(() => {
|
|
37
|
+
if (this.line === null) return 0;
|
|
38
|
+
if (!this.frames) return LINE_HEIGHT * 2; // loading / error placeholder
|
|
39
|
+
let h = FRAME_BLOCK_PAD * 2;
|
|
40
|
+
for (const frame of this.frames) {
|
|
41
|
+
h += LINE_HEIGHT; // per-frame label
|
|
42
|
+
h += frame.length * LINE_HEIGHT; // the frame's rows
|
|
43
|
+
}
|
|
44
|
+
if (this.frames.length > 1) h += FRAME_GAP * (this.frames.length - 1);
|
|
45
|
+
return Math.min(h, FRAME_BLOCK_MAX);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
collapse(): void {
|
|
49
|
+
this.line = null;
|
|
50
|
+
this.frames = null;
|
|
51
|
+
this.failed = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async toggle(lineNum: number): Promise<void> {
|
|
55
|
+
if (this.line === lineNum) {
|
|
56
|
+
this.collapse();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.line = lineNum;
|
|
60
|
+
this.frames = null;
|
|
61
|
+
this.failed = false;
|
|
62
|
+
const fetchHistory = this.#fetch();
|
|
63
|
+
if (!fetchHistory) return;
|
|
64
|
+
try {
|
|
65
|
+
const frames = await fetchHistory(lineNum);
|
|
66
|
+
if (this.line === lineNum) this.frames = frames;
|
|
67
|
+
} catch {
|
|
68
|
+
if (this.line === lineNum) this.failed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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.js";
|
|
6
|
+
import { WrapLayout, wrapColumns } from "./wrap-layout.svelte.js";
|
|
7
|
+
|
|
8
|
+
// 10 columns: (width 96 - gutter 0 - padding 16) / charWidth 8.
|
|
9
|
+
const WIDTH = 96;
|
|
10
|
+
const CHAR_WIDTH = 8;
|
|
11
|
+
|
|
12
|
+
// Effects do not run under the node test environment, so lines are measured the
|
|
13
|
+
// way the console does for streamed text: through measureRange.
|
|
14
|
+
function setup(lines: string[], wrap: boolean) {
|
|
15
|
+
const cache = new LogCache();
|
|
16
|
+
cache.applyEvent({
|
|
17
|
+
lines: Object.fromEntries(lines.map((text, i) => [i, text])),
|
|
18
|
+
sizeLines: lines.length,
|
|
19
|
+
finished: true,
|
|
20
|
+
});
|
|
21
|
+
const layout = new WrapLayout(cache, {
|
|
22
|
+
wrap: () => wrap,
|
|
23
|
+
width: () => WIDTH,
|
|
24
|
+
gutterWidth: () => 0,
|
|
25
|
+
charWidth: () => CHAR_WIDTH,
|
|
26
|
+
});
|
|
27
|
+
layout.measureRange(0, lines.length - 1);
|
|
28
|
+
return { cache, layout };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("wrapColumns", () => {
|
|
32
|
+
it("is the columns left after the gutter and padding", () => {
|
|
33
|
+
expect(wrapColumns(true, WIDTH, 0, CHAR_WIDTH)).toBe(10);
|
|
34
|
+
expect(wrapColumns(true, 200, 40, 8)).toBe(18);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps one count across the pixels of a resize, so it alone can drive a remeasure", () => {
|
|
38
|
+
const counts = new Set([96, 97, 100, 103].map((w) => wrapColumns(true, w, 0, CHAR_WIDTH)));
|
|
39
|
+
expect([...counts]).toEqual([10]);
|
|
40
|
+
expect(wrapColumns(true, 104, 0, CHAR_WIDTH)).toBe(11);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is 1 with wrapping off or before the font is measured", () => {
|
|
44
|
+
expect(wrapColumns(false, WIDTH, 0, CHAR_WIDTH)).toBe(1);
|
|
45
|
+
expect(wrapColumns(true, WIDTH, 0, 0)).toBe(1);
|
|
46
|
+
expect(wrapColumns(true, 10, 0, CHAR_WIDTH)).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("WrapLayout", () => {
|
|
51
|
+
it("treats every line as one row when wrapping is off", () => {
|
|
52
|
+
const { layout } = setup(["a".repeat(100), "b"], false);
|
|
53
|
+
expect(layout.rowCount(0)).toBe(1);
|
|
54
|
+
expect(layout.rowsAbove(1)).toBe(1);
|
|
55
|
+
expect(layout.totalRows).toBe(2);
|
|
56
|
+
expect(layout.offsetAt(1.5)).toBe(1);
|
|
57
|
+
expect(layout.offsetEnd(0, 1.2)).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("counts wrapped rows per line and accumulates positions", () => {
|
|
61
|
+
// 25 columns wrap into 3 rows of 10; 5 columns stay on 1.
|
|
62
|
+
const { layout } = setup(["a".repeat(25), "b".repeat(5), "c".repeat(10)], true);
|
|
63
|
+
expect(layout.rowCount(0)).toBe(3);
|
|
64
|
+
expect(layout.rowCount(1)).toBe(1);
|
|
65
|
+
expect(layout.rowCount(2)).toBe(1);
|
|
66
|
+
expect(layout.rowsAbove(0)).toBe(0);
|
|
67
|
+
expect(layout.rowsAbove(1)).toBe(3);
|
|
68
|
+
expect(layout.rowsAbove(2)).toBe(4);
|
|
69
|
+
expect(layout.totalRows).toBe(5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("maps a scroll offset back to the line covering it", () => {
|
|
73
|
+
const { layout } = setup(["a".repeat(25), "b".repeat(5), "c".repeat(10)], true);
|
|
74
|
+
expect(layout.offsetAt(0)).toBe(0);
|
|
75
|
+
expect(layout.offsetAt(2.9)).toBe(0);
|
|
76
|
+
expect(layout.offsetAt(3)).toBe(1);
|
|
77
|
+
expect(layout.offsetAt(4.5)).toBe(2);
|
|
78
|
+
expect(layout.offsetEnd(0, 3.5)).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("measures a range of lines that arrive later", () => {
|
|
82
|
+
const { cache, layout } = setup([], true);
|
|
83
|
+
const merged = cache.applyEvent({
|
|
84
|
+
lines: { 0: "a".repeat(25) },
|
|
85
|
+
sizeLines: 1,
|
|
86
|
+
finished: false,
|
|
87
|
+
});
|
|
88
|
+
layout.measureRange(merged.min, merged.max);
|
|
89
|
+
expect(layout.rowCount(0)).toBe(3);
|
|
90
|
+
expect(layout.totalRows).toBe(3);
|
|
91
|
+
});
|
|
92
|
+
});
|