@runwisp/ui 1.3.0 → 1.5.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 -6
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/portal.ts +5 -1
- package/src/lib/components/Accordion.svelte +6 -2
- package/src/lib/components/AccordionItem.svelte +80 -23
- package/src/lib/components/Avatar.svelte +0 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +95 -133
- package/src/lib/components/Drawer.svelte +2 -6
- package/src/lib/components/Dropdown.svelte +19 -57
- package/src/lib/components/EmptyState.svelte +2 -1
- package/src/lib/components/ErrorState.svelte +2 -1
- package/src/lib/components/Heading.svelte +19 -12
- package/src/lib/components/Kbd.svelte +25 -10
- package/src/lib/components/Link.svelte +7 -1
- package/src/lib/components/LogConsole.svelte +37 -34
- package/src/lib/components/Modal.svelte +44 -33
- package/src/lib/components/NotificationBell.svelte +2 -1
- package/src/lib/components/PageContainer.svelte +1 -10
- package/src/lib/components/PageHeader.svelte +8 -4
- package/src/lib/components/Popover.svelte +9 -51
- package/src/lib/components/Progress.svelte +19 -6
- package/src/lib/components/Radio.svelte +16 -4
- package/src/lib/components/RadioGroup.svelte +15 -2
- package/src/lib/components/Select.svelte +13 -45
- package/src/lib/components/Skeleton.svelte +6 -6
- package/src/lib/components/Table.svelte +13 -4
- package/src/lib/components/TableBody.svelte +2 -1
- package/src/lib/components/TableCell.svelte +38 -8
- package/src/lib/components/TableHead.svelte +12 -4
- package/src/lib/components/TableRow.svelte +23 -3
- package/src/lib/components/TaskCard.svelte +1 -1
- package/src/lib/components/TimezoneSelect.svelte +2 -2
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +141 -67
- package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
- package/src/lib/components/dashboard/RunsList.svelte +57 -37
- package/src/lib/components/dashboard/cron-format.ts +1 -1
- package/src/lib/components/dashboard/run-filters.test.ts +2 -58
- package/src/lib/components/dashboard/run-filters.ts +5 -53
- package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
- package/src/lib/components/dashboard/run-helpers.ts +12 -1
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/highlight-scroll.test.ts +59 -0
- package/src/lib/log-console/highlight-scroll.ts +41 -0
- package/src/lib/theme.css +10 -4
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.ts +65 -94
- package/src/lib/utils/id.test.ts +3 -7
- package/src/lib/utils/id.ts +3 -2
- package/src/lib/utils/theme.svelte.test.ts +0 -6
- package/src/lib/utils/theme.svelte.ts +3 -5
- package/src/lib/utils/toast.svelte.ts +3 -11
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/Button.stories.svelte +2 -2
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +2 -7
- package/src/stories/Dropdown.stories.svelte +3 -3
- package/src/stories/Heading.stories.svelte +31 -0
- package/src/stories/Kbd.stories.svelte +36 -0
- package/src/stories/Link.stories.svelte +38 -0
- package/src/stories/PageHeader.stories.svelte +31 -0
- package/src/stories/Progress.stories.svelte +26 -0
- package/src/stories/RadioGroup.stories.svelte +43 -0
- package/src/stories/Table.stories.svelte +69 -0
- package/src/svelte-shims.d.ts +0 -5
- package/src/lib/components/dashboard/status-config.test.ts +0 -18
|
@@ -1,32 +1,6 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import type { Task as CommonTask, Run as CommonRun } from "@runwisp/common";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Web UI component types.
|
|
8
|
-
* Source of truth for status values: {@link RunStatus} from `@runwisp/common`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export type Task = CommonTask;
|
|
12
|
-
|
|
13
|
-
export type Run = CommonRun;
|
|
14
|
-
|
|
15
|
-
export interface DaemonState {
|
|
16
|
-
name: string;
|
|
17
|
-
version: string;
|
|
18
|
-
uptime: string;
|
|
19
|
-
status: "connected" | "disconnected";
|
|
20
|
-
host: string;
|
|
21
|
-
backendUrl: string;
|
|
22
|
-
cpus: number;
|
|
23
|
-
memory: string;
|
|
24
|
-
os: string;
|
|
25
|
-
arch: string;
|
|
26
|
-
workDir: string;
|
|
27
|
-
fingerprint: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
4
|
export interface DaemonStats {
|
|
31
5
|
cpuUsage: number;
|
|
32
6
|
memUsage: number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export const RADIO_GROUP_KEY = Symbol("radio-group");
|
|
5
|
+
|
|
6
|
+
// What a RadioGroup hands its Radio children: the shared input name and the
|
|
7
|
+
// selected value, read and written through the group's bindable `value`.
|
|
8
|
+
export interface RadioGroupContext {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
value: string;
|
|
11
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
export { default as theme } from "./theme.css?inline";
|
|
5
|
-
|
|
6
4
|
export { default as Logo } from "./components/Logo.svelte";
|
|
7
5
|
|
|
8
6
|
export { default as PageContainer } from "./components/PageContainer.svelte";
|
|
@@ -19,13 +17,6 @@ export { default as Spinner } from "./components/Spinner.svelte";
|
|
|
19
17
|
|
|
20
18
|
export { default as Button } from "./components/Button.svelte";
|
|
21
19
|
export { default as LinkButton } from "./components/LinkButton.svelte";
|
|
22
|
-
export {
|
|
23
|
-
BUTTON_BASE,
|
|
24
|
-
BUTTON_SIZES,
|
|
25
|
-
BUTTON_VARIANTS,
|
|
26
|
-
type ButtonSize,
|
|
27
|
-
type ButtonVariant,
|
|
28
|
-
} from "./components/button-styles.js";
|
|
29
20
|
export { default as Checkbox } from "./components/Checkbox.svelte";
|
|
30
21
|
export { default as CronInput } from "./components/CronInput.svelte";
|
|
31
22
|
export { default as DurationInput } from "./components/DurationInput.svelte";
|
|
@@ -33,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
|
|
|
33
24
|
export { default as Input } from "./components/Input.svelte";
|
|
34
25
|
export { default as Radio } from "./components/Radio.svelte";
|
|
35
26
|
export { default as RadioGroup } from "./components/RadioGroup.svelte";
|
|
36
|
-
export { default as Select
|
|
27
|
+
export { default as Select } from "./components/Select.svelte";
|
|
37
28
|
export { default as Textarea } from "./components/Textarea.svelte";
|
|
38
29
|
export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
|
|
39
30
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
@@ -88,71 +79,45 @@ export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
|
88
79
|
|
|
89
80
|
export { default as CodeBlock } from "./components/CodeBlock.svelte";
|
|
90
81
|
export { default as TaskCard } from "./components/TaskCard.svelte";
|
|
91
|
-
export type { TaskCardAccent } from "./components/TaskCard.svelte";
|
|
92
82
|
|
|
93
83
|
export { default as LogConsole } from "./components/LogConsole.svelte";
|
|
94
84
|
export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
|
|
95
|
-
export { isLogEvent } from "./log-console/types.js";
|
|
96
|
-
export { LogCache } from "./log-console/LogCache.svelte.js";
|
|
97
|
-
export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
|
|
98
85
|
|
|
99
86
|
export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
|
|
100
87
|
export { default as RunsList } from "./components/dashboard/RunsList.svelte";
|
|
101
88
|
export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
|
|
102
89
|
export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
|
|
103
90
|
export {
|
|
91
|
+
activeFilterCount,
|
|
104
92
|
emptyRunFilters,
|
|
105
93
|
FAILURE_STATUS_TOKEN,
|
|
106
|
-
NEEDS_ATTENTION_STATUSES,
|
|
107
|
-
isNeedsAttention,
|
|
108
|
-
activeFilterCount,
|
|
109
|
-
activeDimensions,
|
|
110
|
-
dimensionActive,
|
|
111
|
-
clearDimension,
|
|
112
|
-
clearPopoverFilters,
|
|
113
94
|
humanizeStatus,
|
|
114
|
-
statusChipLabel,
|
|
115
95
|
exitCodeRange,
|
|
116
96
|
type ExitCodeRange,
|
|
117
97
|
type RunsListFilters,
|
|
118
|
-
type RunsListSortDirection,
|
|
119
|
-
type FilterDimension,
|
|
120
98
|
} from "./components/dashboard/run-filters.js";
|
|
121
|
-
export {
|
|
122
|
-
getRunStatusConfig,
|
|
123
|
-
runDisplayStatus,
|
|
124
|
-
RUN_STATUS_CONFIG,
|
|
125
|
-
type RunStatusConfig,
|
|
126
|
-
} from "./components/dashboard/status-config.js";
|
|
99
|
+
export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
|
|
127
100
|
export {
|
|
128
101
|
runDuration,
|
|
129
|
-
runStartDelay,
|
|
130
102
|
runPhaseOrder,
|
|
131
103
|
formatTriggeredByLabel,
|
|
132
|
-
runRetryLabel,
|
|
133
104
|
instanceSuffix,
|
|
134
105
|
} from "./components/dashboard/run-helpers.js";
|
|
135
|
-
export { humanizeCron
|
|
136
|
-
export type {
|
|
106
|
+
export { humanizeCron } from "./components/dashboard/cron-format.js";
|
|
107
|
+
export type { DaemonStats } from "./components/dashboard/types.js";
|
|
137
108
|
|
|
138
|
-
export { toast
|
|
139
|
-
export {
|
|
140
|
-
themeStore,
|
|
141
|
-
THEME_STORAGE_KEY,
|
|
142
|
-
type ThemePreference,
|
|
143
|
-
type ResolvedTheme,
|
|
144
|
-
} from "./utils/theme.svelte.js";
|
|
109
|
+
export { toast } from "./utils/toast.svelte.js";
|
|
145
110
|
export { extractErrorMessage } from "./utils/error.js";
|
|
146
111
|
export {
|
|
147
112
|
formatBytes,
|
|
148
113
|
formatRelativeTime,
|
|
149
114
|
formatRelativeTimeWithAbsolute,
|
|
150
|
-
formatDateTime,
|
|
151
115
|
formatDuration,
|
|
152
116
|
formatFullDateTime,
|
|
153
117
|
} from "./utils/format.js";
|
|
154
118
|
export { formatShortId } from "./utils/id.js";
|
|
155
119
|
export { debounce } from "./utils/debounce.js";
|
|
156
120
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
121
|
+
export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
|
|
157
122
|
export { RunMotion } from "./utils/run-motion.js";
|
|
158
|
-
export { arrival, leave, shift
|
|
123
|
+
export { arrival, leave, shift } from "./actions/row-motion.js";
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { createLogger } from "@runwisp/common";
|
|
5
5
|
import { SvelteSet } from "svelte/reactivity";
|
|
6
6
|
import type { LogCache } from "./LogCache.svelte.js";
|
|
7
7
|
import type { FetchLogsFn } from "./types.js";
|
|
8
8
|
import { isLogEvent } from "./types.js";
|
|
9
9
|
|
|
10
|
-
const createLogger = createLoggerFactory();
|
|
11
10
|
const logger = createLogger("LogFetcher");
|
|
12
11
|
|
|
13
12
|
/**
|
|
@@ -20,8 +19,8 @@ const logger = createLogger("LogFetcher");
|
|
|
20
19
|
*/
|
|
21
20
|
export class LogFetcher {
|
|
22
21
|
private readonly cache: LogCache;
|
|
23
|
-
private fetchLogsFn: FetchLogsFn | undefined;
|
|
24
|
-
private chunkSize
|
|
22
|
+
private readonly fetchLogsFn: FetchLogsFn | undefined;
|
|
23
|
+
private readonly chunkSize = 4096;
|
|
25
24
|
|
|
26
25
|
readonly MAX_INFLIGHT = 2;
|
|
27
26
|
readonly MIN_REQUEST_INTERVAL_MS = 140;
|
|
@@ -38,23 +37,13 @@ export class LogFetcher {
|
|
|
38
37
|
constructor(
|
|
39
38
|
cache: LogCache,
|
|
40
39
|
fetchLogsFn: FetchLogsFn | undefined,
|
|
41
|
-
chunkSize: number = 4096,
|
|
42
40
|
onDataLoaded?: (min: number, max: number) => void,
|
|
43
41
|
) {
|
|
44
42
|
this.cache = cache;
|
|
45
43
|
this.fetchLogsFn = fetchLogsFn;
|
|
46
|
-
this.chunkSize = Math.max(1, Math.floor(chunkSize));
|
|
47
44
|
this.onDataLoaded = onDataLoaded;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
setFetchLogsFn(fn: FetchLogsFn | undefined) {
|
|
51
|
-
this.fetchLogsFn = fn;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
setChunkSize(size: number) {
|
|
55
|
-
this.chunkSize = Math.max(1, Math.floor(size));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
47
|
enqueue(from: number, to: number) {
|
|
59
48
|
if (!this.fetchLogsFn) return;
|
|
60
49
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createHighlightScroll } from "./highlight-scroll.js";
|
|
6
|
+
|
|
7
|
+
// `sync` is what LogConsole calls inside its $effect. The effect re-runs it
|
|
8
|
+
// whenever something it READ changes, so "no layout reads after the reveal"
|
|
9
|
+
// (ready not consulted again) is exactly "layout changes can't re-scroll".
|
|
10
|
+
function setup(initial: { line: number | null; ready: boolean }) {
|
|
11
|
+
const state = { ...initial };
|
|
12
|
+
const ready = vi.fn(() => state.ready);
|
|
13
|
+
const reveal = vi.fn();
|
|
14
|
+
const clear = vi.fn();
|
|
15
|
+
const sync = createHighlightScroll({ line: () => state.line, ready, reveal, clear });
|
|
16
|
+
return { state, ready, reveal, clear, sync };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("createHighlightScroll", () => {
|
|
20
|
+
it("reveals once and stops reading layout afterwards", () => {
|
|
21
|
+
const { ready, reveal, sync } = setup({ line: 5, ready: true });
|
|
22
|
+
sync();
|
|
23
|
+
expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
|
|
24
|
+
expect(ready).toHaveBeenCalledTimes(1);
|
|
25
|
+
|
|
26
|
+
// A running run appends lines or the pane resizes: the effect re-runs,
|
|
27
|
+
// but must neither scroll again nor re-read the layout.
|
|
28
|
+
sync();
|
|
29
|
+
sync();
|
|
30
|
+
expect(reveal).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(ready).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("waits until the line can be placed, then reveals once", () => {
|
|
35
|
+
const { state, reveal, sync } = setup({ line: 5, ready: false });
|
|
36
|
+
sync();
|
|
37
|
+
expect(reveal).not.toHaveBeenCalled();
|
|
38
|
+
state.ready = true;
|
|
39
|
+
sync();
|
|
40
|
+
sync();
|
|
41
|
+
expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reveals again for a new line and after the highlight is cleared", () => {
|
|
45
|
+
const { state, reveal, clear, sync } = setup({ line: 5, ready: true });
|
|
46
|
+
sync();
|
|
47
|
+
state.line = 9;
|
|
48
|
+
sync();
|
|
49
|
+
expect(reveal).toHaveBeenLastCalledWith(9);
|
|
50
|
+
|
|
51
|
+
state.line = null;
|
|
52
|
+
sync();
|
|
53
|
+
expect(clear).toHaveBeenCalledOnce();
|
|
54
|
+
|
|
55
|
+
state.line = 9;
|
|
56
|
+
sync();
|
|
57
|
+
expect(reveal).toHaveBeenCalledTimes(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
|
|
6
|
+
export interface HighlightScrollOptions {
|
|
7
|
+
/** Reactive getter for the line to reveal; null/undefined means none. */
|
|
8
|
+
line: () => number | null | undefined;
|
|
9
|
+
/** Reactive getter: true once the layout can place `line` (container measured, line exists). */
|
|
10
|
+
ready: (line: number) => boolean;
|
|
11
|
+
/** Scrolls to `line`. Runs untracked, so layout reads here never re-trigger the sync. */
|
|
12
|
+
reveal: (line: number) => void;
|
|
13
|
+
/** Called when the highlight is cleared. */
|
|
14
|
+
clear: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns a function to call from inside an `$effect`. It reveals each
|
|
19
|
+
* highlighted line exactly once: after the first reveal, layout changes (new
|
|
20
|
+
* log lines, resizes) no longer re-run the scroll, so the operator can scroll
|
|
21
|
+
* away from a deep-linked search hit and stay there. It waits until `ready`,
|
|
22
|
+
* and a changed or re-set `line` reveals again.
|
|
23
|
+
*/
|
|
24
|
+
export function createHighlightScroll(options: HighlightScrollOptions): () => void {
|
|
25
|
+
let revealed: number | null = null;
|
|
26
|
+
return () => {
|
|
27
|
+
const target = options.line();
|
|
28
|
+
// Not `!target`: line 0 is a valid highlight.
|
|
29
|
+
if (typeof target !== "number") {
|
|
30
|
+
revealed = null;
|
|
31
|
+
options.clear();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Checked before `ready` so a revealed line leaves no layout dependency behind.
|
|
35
|
+
if (revealed === target || !options.ready(target)) return;
|
|
36
|
+
revealed = target;
|
|
37
|
+
untrack(() => {
|
|
38
|
+
options.reveal(target);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/lib/theme.css
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
@plugin "@tailwindcss/typography";
|
|
20
20
|
|
|
21
21
|
@theme {
|
|
22
|
+
/* Wide enough for the sidebar, run list and run detail to stay side by
|
|
23
|
+
* side for good; below it the operator can fold the first two away. */
|
|
24
|
+
--breakpoint-3xl: 100rem;
|
|
25
|
+
|
|
22
26
|
/* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
|
|
23
27
|
* resolve through to --rw-color-* declared in theme-tokens.css. */
|
|
24
28
|
--color-wisp-50: var(--rw-color-wisp-50);
|
|
@@ -251,10 +255,12 @@ body {
|
|
|
251
255
|
color: var(--rw-on-surface);
|
|
252
256
|
}
|
|
253
257
|
|
|
254
|
-
/* Headings are mono chrome — the runbook voice.
|
|
255
|
-
h1
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
/* Headings are mono chrome — the runbook voice. Unlayered, so it beats the
|
|
259
|
+
weight/leading/tracking utilities on a raw <h1>-<h3>: every heading reads the
|
|
260
|
+
same. The Heading component opts out (data-rw-heading) and carries this look
|
|
261
|
+
in its own classes, so its props are the source of truth. :is()/:where()
|
|
262
|
+
keep the selector at element specificity, so app stylesheets still win. */
|
|
263
|
+
:is(h1, h2, h3):where(:not([data-rw-heading])) {
|
|
258
264
|
font-family: var(--font-mono);
|
|
259
265
|
font-weight: 800;
|
|
260
266
|
line-height: 1.1;
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { CopyFeedback, copyText } from "./clipboard.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("copyText", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("writes through navigator.clipboard", async () => {
|
|
13
|
+
const writeText = vi.fn(() => Promise.resolve());
|
|
14
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
15
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
16
|
+
expect(writeText).toHaveBeenCalledWith("runwisp");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to execCommand when the clipboard API is missing", async () => {
|
|
20
|
+
vi.stubGlobal("navigator", {});
|
|
21
|
+
const textarea = {
|
|
22
|
+
value: "",
|
|
23
|
+
readOnly: false,
|
|
24
|
+
style: {},
|
|
25
|
+
select: vi.fn(),
|
|
26
|
+
remove: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
const execCommand = vi.fn(() => true);
|
|
29
|
+
class FakeElement {
|
|
30
|
+
focus = vi.fn();
|
|
31
|
+
}
|
|
32
|
+
const focused = new FakeElement();
|
|
33
|
+
vi.stubGlobal("HTMLElement", FakeElement);
|
|
34
|
+
vi.stubGlobal("document", {
|
|
35
|
+
activeElement: focused,
|
|
36
|
+
createElement: () => textarea,
|
|
37
|
+
body: { appendChild: vi.fn() },
|
|
38
|
+
execCommand,
|
|
39
|
+
});
|
|
40
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
41
|
+
expect(textarea.value).toBe("runwisp");
|
|
42
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
43
|
+
expect(textarea.remove).toHaveBeenCalled();
|
|
44
|
+
expect(focused.focus).toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports failure when no copy path exists", async () => {
|
|
48
|
+
vi.stubGlobal("navigator", {});
|
|
49
|
+
vi.stubGlobal("document", undefined);
|
|
50
|
+
expect(await copyText("runwisp")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("CopyFeedback", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
vi.stubGlobal("navigator", { clipboard: { writeText: () => Promise.resolve() } });
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags the copied key until the reset delay passes", async () => {
|
|
65
|
+
const feedback = new CopyFeedback(1000);
|
|
66
|
+
await feedback.copy("docker run", "docker");
|
|
67
|
+
expect(feedback.copied).toBe(true);
|
|
68
|
+
expect(feedback.isCopied("docker")).toBe(true);
|
|
69
|
+
expect(feedback.isCopied("binary")).toBe(false);
|
|
70
|
+
vi.advanceTimersByTime(1000);
|
|
71
|
+
expect(feedback.copied).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("restarts the reset delay on a second copy", async () => {
|
|
75
|
+
const feedback = new CopyFeedback(1000);
|
|
76
|
+
await feedback.copy("a");
|
|
77
|
+
vi.advanceTimersByTime(800);
|
|
78
|
+
await feedback.copy("b");
|
|
79
|
+
vi.advanceTimersByTime(800);
|
|
80
|
+
expect(feedback.copied).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stays idle when the copy fails", async () => {
|
|
84
|
+
vi.stubGlobal("navigator", {
|
|
85
|
+
clipboard: { writeText: () => Promise.reject(new Error("denied")) },
|
|
86
|
+
});
|
|
87
|
+
vi.stubGlobal("document", undefined);
|
|
88
|
+
const feedback = new CopyFeedback();
|
|
89
|
+
expect(await feedback.copy("a")).toBe(false);
|
|
90
|
+
expect(feedback.copied).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RESET_MS = 2000;
|
|
5
|
+
|
|
6
|
+
// Copies text to the clipboard and reports whether it worked. navigator.clipboard
|
|
7
|
+
// only exists in secure contexts, so a daemon served over plain http on a LAN IP
|
|
8
|
+
// falls back to the legacy textarea + execCommand path.
|
|
9
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
10
|
+
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
|
|
11
|
+
try {
|
|
12
|
+
await navigator.clipboard.writeText(text);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
// Denied or blocked: try the legacy path below.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return legacyCopy(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function legacyCopy(text: string): boolean {
|
|
22
|
+
if (typeof document === "undefined") return false;
|
|
23
|
+
const previousFocus = document.activeElement;
|
|
24
|
+
const ta = document.createElement("textarea");
|
|
25
|
+
ta.value = text;
|
|
26
|
+
ta.readOnly = true;
|
|
27
|
+
ta.style.position = "fixed";
|
|
28
|
+
ta.style.opacity = "0";
|
|
29
|
+
document.body.appendChild(ta);
|
|
30
|
+
ta.select();
|
|
31
|
+
let ok = false;
|
|
32
|
+
try {
|
|
33
|
+
// Deprecated, but still the only copy path outside a secure context.
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
35
|
+
ok = document.execCommand("copy"); // NOSONAR
|
|
36
|
+
} catch {
|
|
37
|
+
ok = false;
|
|
38
|
+
}
|
|
39
|
+
ta.remove();
|
|
40
|
+
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
|
41
|
+
return ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CopyFeedback drives a copy button's transient "copied" state. `key` tells
|
|
45
|
+
// several buttons sharing one instance apart (one per tab or snippet).
|
|
46
|
+
export class CopyFeedback {
|
|
47
|
+
#key = $state<string | null>(null);
|
|
48
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
readonly #resetMs: number;
|
|
50
|
+
|
|
51
|
+
constructor(resetMs: number = DEFAULT_RESET_MS) {
|
|
52
|
+
this.#resetMs = resetMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get copied(): boolean {
|
|
56
|
+
return this.#key !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isCopied(key: string): boolean {
|
|
60
|
+
return this.#key === key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async copy(text: string, key: string = ""): Promise<boolean> {
|
|
64
|
+
const ok = await copyText(text);
|
|
65
|
+
if (!ok) return false;
|
|
66
|
+
clearTimeout(this.#timer);
|
|
67
|
+
this.#key = key;
|
|
68
|
+
this.#timer = setTimeout(() => {
|
|
69
|
+
this.#key = null;
|
|
70
|
+
}, this.#resetMs);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|