dsh-plugin-container 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -2
  3. package/cordis.patch.yml +3 -0
  4. package/docs/ai-chat.png +0 -0
  5. package/docs/ai-new-session.png +0 -0
  6. package/docs/apple-container.png +0 -0
  7. package/docs/attach.png +0 -0
  8. package/docs/docker-console.png +0 -0
  9. package/docs/docker-overview.png +0 -0
  10. package/docs/docker-volumes.png +0 -0
  11. package/docs/image-architecture.png +0 -0
  12. package/docs/package-install.png +0 -0
  13. package/docs/provider-switch.png +0 -0
  14. package/docs/run-container.png +0 -0
  15. package/index.js +138 -0
  16. package/lib/client.js +3499 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +94 -0
  19. package/src/client/attach.jsx +133 -0
  20. package/src/client/components.jsx +301 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +496 -0
  23. package/src/client/icons.jsx +219 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +755 -0
  26. package/src/client/run-container.jsx +187 -0
  27. package/src/client/runs.js +182 -0
  28. package/src/client/store.js +91 -0
  29. package/src/client/styles.js +363 -0
  30. package/src/client/tabs/compose.jsx +94 -0
  31. package/src/client/tabs/console.jsx +211 -0
  32. package/src/client/tabs/containers.jsx +143 -0
  33. package/src/client/tabs/images.jsx +127 -0
  34. package/src/client/tabs/networks.jsx +91 -0
  35. package/src/client/tabs/overview.jsx +266 -0
  36. package/src/client/tabs/volumes.jsx +76 -0
  37. package/src/host/apple-cli.js +307 -0
  38. package/src/host/apple.js +471 -0
  39. package/src/host/attach.js +243 -0
  40. package/src/host/commands.js +235 -0
  41. package/src/host/docker-cli.js +405 -0
  42. package/src/host/jobs.js +245 -0
  43. package/src/host/parsers.js +484 -0
  44. package/src/host/runtime.js +285 -0
  45. package/src/host/service.js +832 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "dsh-plugin-container",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "DSH sidebar container panel for macOS Docker runtimes and Apple container",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "lib/client.js",
16
+ "src/**/*.js",
17
+ "src/**/*.jsx",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "docs/*.png"
21
+ ],
22
+ "dsh": {
23
+ "client": {
24
+ "platform": "web",
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-connection",
27
+ "@deepseek-ai/dsh-client-ui-layout",
28
+ "@deepseek-ai/dsh-client-ui-sidebar",
29
+ "@deepseek-ai/dsh-client-locale"
30
+ ]
31
+ },
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "node scripts/build-client.mjs",
38
+ "install-profile": "node scripts/install-profile.mjs",
39
+ "test": "node --test test/*.test.mjs",
40
+ "prepack": "npm run build && npm test"
41
+ },
42
+ "devDependencies": {
43
+ "esbuild": "0.25.0"
44
+ },
45
+ "dependencies": {
46
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.5"
47
+ },
48
+ "license": "MIT",
49
+ "keywords": [
50
+ "dsh",
51
+ "dsh-plugin",
52
+ "deepseek-harness",
53
+ "docker",
54
+ "apple-container",
55
+ "macos",
56
+ "containers"
57
+ ],
58
+ "engines": {
59
+ "node": ">=22"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,94 @@
1
+ /** Typed wrapper around the host `/docker` Connection RPC channel. */
2
+
3
+ export const DOCKER_CHANNEL = '/docker'
4
+
5
+ export class DockerApiError extends Error {
6
+ /**
7
+ * @param {string} code
8
+ * @param {string} message
9
+ * @param {Record<string, any>} [details]
10
+ * @param {unknown} [cause]
11
+ */
12
+ constructor(code, message, details = {}, cause = undefined) {
13
+ super(message)
14
+ this.name = 'DockerApiError'
15
+ this.code = code
16
+ this.details = details
17
+ this.cause = cause
18
+ }
19
+ }
20
+
21
+ /** @param {unknown} error */
22
+ export function isAbortError(error) {
23
+ return error instanceof DOMException && error.name === 'AbortError' || error?.name === 'AbortError'
24
+ }
25
+
26
+ /** @param {unknown} value */
27
+ function isResult(value) {
28
+ return typeof value === 'object' && value !== null && typeof value.ok === 'boolean'
29
+ }
30
+
31
+ /** @param {any} connection */
32
+ export function createApi(connection) {
33
+ return {
34
+ connection,
35
+ /**
36
+ * @param {string} method
37
+ * @param {Record<string, any>} payload
38
+ * @param {AbortSignal} [signal]
39
+ */
40
+ async call(method, payload = {}, signal = undefined) {
41
+ let result
42
+ try {
43
+ result = await connection.rpc.call(DOCKER_CHANNEL, method, payload, signal)
44
+ } catch (error) {
45
+ if (isAbortError(error)) throw new DockerApiError('docker/cancelled', '请求已取消', {}, error)
46
+ throw new DockerApiError('docker/transport', transportMessage(error), {}, error)
47
+ }
48
+ if (!isResult(result)) throw new DockerApiError('docker/protocol', 'Docker 服务返回了无效响应')
49
+ if (result.ok !== true) {
50
+ const error = result.error ?? {}
51
+ throw new DockerApiError(
52
+ typeof error.code === 'string' ? error.code : 'docker/internal',
53
+ typeof error.message === 'string' ? error.message : 'Docker 服务调用失败',
54
+ error.details ?? {},
55
+ )
56
+ }
57
+ return result.value
58
+ },
59
+ }
60
+ }
61
+
62
+ /** @param {unknown} error */
63
+ function transportMessage(error) {
64
+ const message = error instanceof Error ? error.message : String(error)
65
+ if (/HTTP 404|not found/iu.test(message)) return 'Docker 服务未加载(/docker 通道不可用),请刷新页面或重启 DSH'
66
+ if (/HTTP 500/iu.test(message)) return 'Docker 服务内部错误,请查看 DSH 日志'
67
+ if (/HTTP 401|HTTP 403/iu.test(message)) return '没有权限访问本机 Docker 服务'
68
+ return `Docker 服务连接失败:${message}`
69
+ }
70
+
71
+ /** Module-level query cache used by the tabs. Values only, never promises. */
72
+ const queryCache = new Map()
73
+
74
+ /** @param {string} key */
75
+ export function cacheGet(key) {
76
+ const hit = queryCache.get(key)
77
+ if (hit === undefined) return undefined
78
+ if (Date.now() - hit.at > hit.ttl) {
79
+ queryCache.delete(key)
80
+ return undefined
81
+ }
82
+ return hit.value
83
+ }
84
+
85
+ /** @param {string} key @param {unknown} value @param {number} [ttl] */
86
+ export function cacheSet(key, value, ttl = 2000) {
87
+ queryCache.set(key, { at: Date.now(), value, ttl })
88
+ }
89
+
90
+ /** @param {string} [prefix] */
91
+ export function cacheClear(prefix = '') {
92
+ if (prefix === '') queryCache.clear()
93
+ else for (const key of [...queryCache.keys()]) if (key.startsWith(prefix)) queryCache.delete(key)
94
+ }
@@ -0,0 +1,133 @@
1
+ /** Interactive PTY attach viewer: raw output plus keyboard/paste input. */
2
+ import { useEffect, useRef, useState } from 'react'
3
+
4
+ import { Button, CopyButton, Modal, Spinner } from './components.jsx'
5
+
6
+ /** Keep common terminal output readable without pulling xterm into this modal. */
7
+ function cleanTerminal(text) {
8
+ return text
9
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/gu, '')
10
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, '')
11
+ .replace(/\x1b[@-_]/gu, '')
12
+ .replace(/\r/gu, '')
13
+ }
14
+
15
+ function statusLabel(t, status) {
16
+ if (status === 'running') return t('attachRunning')
17
+ if (status === 'exited') return t('attachExited')
18
+ if (status === 'failed') return t('attachFailed')
19
+ if (status === 'closed') return t('attachClosed')
20
+ return t('attachStarting')
21
+ }
22
+
23
+ function keyBytes(event) {
24
+ if (event.metaKey) return null
25
+ if (event.ctrlKey && event.key.length === 1) {
26
+ const code = event.key.toUpperCase().charCodeAt(0)
27
+ if (code >= 64 && code <= 95) return String.fromCharCode(code - 64)
28
+ return null
29
+ }
30
+ if (event.altKey) return null
31
+ if (event.key === 'Enter') return '\r'
32
+ if (event.key === 'Backspace') return '\x7f'
33
+ if (event.key === 'Tab') return '\t'
34
+ if (event.key === 'Escape') return '\x1b'
35
+ if (event.key === 'ArrowUp') return '\x1b[A'
36
+ if (event.key === 'ArrowDown') return '\x1b[B'
37
+ if (event.key === 'ArrowRight') return '\x1b[C'
38
+ if (event.key === 'ArrowLeft') return '\x1b[D'
39
+ if (event.key === 'Home') return '\x1b[H'
40
+ if (event.key === 'End') return '\x1b[F'
41
+ if (event.key === 'Delete') return '\x1b[3~'
42
+ if (event.key === 'PageUp') return '\x1b[5~'
43
+ if (event.key === 'PageDown') return '\x1b[6~'
44
+ if (event.key.length === 1) return event.key
45
+ return null
46
+ }
47
+
48
+ export default function AttachDialog({ open, t, session, output, loading, error, onInput, onClose }) {
49
+ const screenRef = useRef(null)
50
+ const preRef = useRef(null)
51
+ const [stick, setStick] = useState(true)
52
+
53
+ useEffect(() => {
54
+ if (!open) return undefined
55
+ setStick(true)
56
+ const timer = setTimeout(() => screenRef.current?.focus(), 60)
57
+ return () => clearTimeout(timer)
58
+ }, [open])
59
+
60
+ useEffect(() => {
61
+ if (!stick) return
62
+ const element = preRef.current
63
+ if (element !== null) element.scrollTop = element.scrollHeight
64
+ }, [output, stick])
65
+
66
+ const running = session?.status === 'running'
67
+ const shown = cleanTerminal(output)
68
+ return (
69
+ <Modal
70
+ open={open}
71
+ title={`${t('attachTitle')} · ${session?.containerName ?? ''}`}
72
+ subtitle={session?.command ?? undefined}
73
+ wide
74
+ onClose={onClose}
75
+ closeLabel={t('close')}
76
+ footer={
77
+ <>
78
+ <span className="dsh-docker-muted">{t('attachInputHint')}</span>
79
+ <span style={{ flex: 1 }} />
80
+ {session?.exitCode !== null && session?.exitCode !== undefined && <span className="dsh-docker-muted">{t('exitCode')}: {session.exitCode}</span>}
81
+ <CopyButton text={shown} label={t('copy')} copiedLabel={t('copied')} />
82
+ <Button size="sm" onClick={onClose}>{t('attachClose')}</Button>
83
+ </>
84
+ }
85
+ >
86
+ <div className="dsh-docker-attach-status">
87
+ <span className="dsh-docker-status" data-state={running ? 'running' : session?.status === 'failed' ? 'error' : 'stopped'}>
88
+ <span className="dsh-docker-dot" />
89
+ {statusLabel(t, session?.status)}
90
+ </span>
91
+ <span className="dsh-docker-muted">{session?.cols ?? 120} × {session?.rows ?? 30}</span>
92
+ </div>
93
+ <div
94
+ ref={screenRef}
95
+ className="dsh-docker-attach-screen"
96
+ tabIndex={0}
97
+ role="textbox"
98
+ aria-label={t('attachTitle')}
99
+ onKeyDown={(event) => {
100
+ if (!running || loading) return
101
+ const data = keyBytes(event)
102
+ if (data === null) return
103
+ event.preventDefault()
104
+ onInput(data)
105
+ }}
106
+ onPaste={(event) => {
107
+ if (!running || loading) return
108
+ event.preventDefault()
109
+ const text = event.clipboardData.getData('text')
110
+ if (text !== '') onInput(text)
111
+ }}
112
+ >
113
+ {loading ? (
114
+ <div className="dsh-docker-attach-loading"><Spinner size={16} /> {t('attachStarting')}</div>
115
+ ) : error !== null ? (
116
+ <pre className="dsh-docker-attach-pre dsh-docker-attach-error">{error.message ?? String(error)}</pre>
117
+ ) : shown === '' ? (
118
+ <pre className="dsh-docker-attach-pre dsh-docker-no-output">{t('attachHint')}</pre>
119
+ ) : (
120
+ <pre
121
+ ref={preRef}
122
+ className="dsh-docker-attach-pre"
123
+ onScroll={() => {
124
+ const element = preRef.current
125
+ if (element === null) return
126
+ setStick(element.scrollHeight - element.scrollTop - element.clientHeight < 28)
127
+ }}
128
+ >{shown}</pre>
129
+ )}
130
+ </div>
131
+ </Modal>
132
+ )
133
+ }
@@ -0,0 +1,301 @@
1
+ /** Small presentational building blocks shared by the Docker tabs. */
2
+ import { useEffect, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+
5
+ import { IconAlert, IconCheck, IconCopy, IconSearch, IconSpinner, IconX } from './icons.jsx'
6
+
7
+ export function cx(...values) {
8
+ return values.filter(Boolean).join(' ')
9
+ }
10
+
11
+ export function Button({ variant = 'ghost', size = 'md', className, type = 'button', children, ...rest }) {
12
+ return (
13
+ <button
14
+ type={type}
15
+ className={cx(
16
+ 'dsh-docker-btn',
17
+ variant === 'primary' && 'dsh-docker-primary',
18
+ variant === 'danger' && 'dsh-docker-danger',
19
+ variant === 'danger-solid' && 'dsh-docker-danger-solid',
20
+ variant === 'ghost' && 'dsh-docker-ghost',
21
+ size === 'sm' && 'dsh-docker-sm',
22
+ size === 'lg' && 'dsh-docker-lg',
23
+ className,
24
+ )}
25
+ {...rest}
26
+ >
27
+ {children}
28
+ </button>
29
+ )
30
+ }
31
+
32
+ export function IconButton({ label, size = 'md', className, children, ...rest }) {
33
+ return (
34
+ <button
35
+ type="button"
36
+ aria-label={label}
37
+ title={label}
38
+ className={cx('dsh-docker-icon-btn', size === 'sm' && 'dsh-docker-sm', className)}
39
+ {...rest}
40
+ >
41
+ {children}
42
+ </button>
43
+ )
44
+ }
45
+
46
+ export function Spinner({ size = 16, className }) {
47
+ return <IconSpinner size={size} className={className} />
48
+ }
49
+
50
+ export function StatusDot({ state = 'unknown' }) {
51
+ return <span className="dsh-docker-dot" data-state={state} aria-hidden="true" />
52
+ }
53
+
54
+ export function StateLabel({ state, t }) {
55
+ const normalized = state === 'running' ? 'running'
56
+ : state === 'restarting' ? 'restarting'
57
+ : state === 'paused' ? 'paused'
58
+ : state === 'dead' ? 'dead'
59
+ : 'exited'
60
+ const label = normalized === 'running' ? t('running')
61
+ : normalized === 'restarting' ? t('statusStarting')
62
+ : normalized === 'paused' ? t('statusStarting')
63
+ : normalized === 'dead' ? t('failed')
64
+ : t('stopped')
65
+ return (
66
+ <span className="dsh-docker-state" data-state={normalized}>
67
+ <StatusDot state={normalized} />
68
+ {label}
69
+ </span>
70
+ )
71
+ }
72
+
73
+ export function Modal({ open, title, subtitle, wide, onClose, children, footer, closeLabel = 'Close' }) {
74
+ useEffect(() => {
75
+ if (!open) return undefined
76
+ const onKey = (event) => {
77
+ if (event.key === 'Escape') {
78
+ event.stopPropagation()
79
+ onClose?.()
80
+ }
81
+ }
82
+ document.addEventListener('keydown', onKey)
83
+ return () => document.removeEventListener('keydown', onKey)
84
+ }, [open, onClose])
85
+
86
+ if (!open) return null
87
+ return createPortal(
88
+ <div
89
+ className="dsh-docker-modal-backdrop"
90
+ onMouseDown={(event) => {
91
+ if (event.target === event.currentTarget) onClose?.()
92
+ }}
93
+ >
94
+ <div
95
+ className={cx('dsh-docker-modal', wide && 'dsh-docker-modal-wide')}
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-label={title}
99
+ >
100
+ <div className="dsh-docker-modal-head">
101
+ <div>
102
+ <h2 className="dsh-docker-modal-title">{title}</h2>
103
+ {subtitle !== undefined && <p className="dsh-docker-modal-sub">{subtitle}</p>}
104
+ </div>
105
+ <IconButton label={closeLabel} autoFocus onClick={onClose}>
106
+ <IconX size={16} />
107
+ </IconButton>
108
+ </div>
109
+ <div className="dsh-docker-modal-body">{children}</div>
110
+ {footer !== undefined && <div className="dsh-docker-modal-foot">{footer}</div>}
111
+ </div>
112
+ </div>,
113
+ document.body,
114
+ )
115
+ }
116
+
117
+ export function ConfirmDialog({ open, title, body, command, confirmLabel, cancelLabel, closeLabel = 'Close', danger = false, busy = false, onConfirm, onCancel }) {
118
+ return (
119
+ <Modal
120
+ open={open}
121
+ title={title}
122
+ closeLabel={closeLabel}
123
+ onClose={onCancel}
124
+ footer={
125
+ <>
126
+ <Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
127
+ <Button variant={danger ? 'danger-solid' : 'primary'} onClick={onConfirm} disabled={busy}>
128
+ {busy ? <Spinner size={14} /> : null}
129
+ {confirmLabel}
130
+ </Button>
131
+ </>
132
+ }
133
+ >
134
+ <div className={cx(danger && 'dsh-docker-modal-danger')} style={{ whiteSpace: 'pre-wrap' }}>
135
+ <div>{body}</div>
136
+ {command !== undefined && (
137
+ <pre className="dsh-docker-json" style={{ marginTop: 10, whiteSpace: 'pre-wrap' }}>{command}</pre>
138
+ )}
139
+ </div>
140
+ </Modal>
141
+ )
142
+ }
143
+
144
+ export function ToastStack({ toasts, onDismiss }) {
145
+ if (toasts.length === 0) return null
146
+ return createPortal(
147
+ <div className="dsh-docker-toast-wrap" aria-live="polite">
148
+ {toasts.map((toast) => (
149
+ <div key={toast.id} className="dsh-docker-toast" data-tone={toast.tone}>
150
+ <span style={{ marginTop: 1 }}>
151
+ {toast.tone === 'success' ? <IconCheck size={14} /> : toast.tone === 'error' ? <IconAlert size={14} /> : null}
152
+ </span>
153
+ <span className="dsh-docker-toast-main">{toast.message}</span>
154
+ <IconButton label="Close" size="sm" onClick={() => onDismiss(toast.id)}>
155
+ <IconX size={13} />
156
+ </IconButton>
157
+ </div>
158
+ ))}
159
+ </div>,
160
+ document.body,
161
+ )
162
+ }
163
+
164
+ export function EmptyState({ icon, title, text, action }) {
165
+ return (
166
+ <div className="dsh-docker-empty">
167
+ {icon !== undefined && <span className="dsh-docker-empty-icon">{icon}</span>}
168
+ <div className="dsh-docker-empty-title">{title}</div>
169
+ {text !== undefined && <div className="dsh-docker-empty-text">{text}</div>}
170
+ {action}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export function ErrorState({ title, text, retryLabel, onRetry }) {
176
+ return (
177
+ <div className="dsh-docker-error">
178
+ <span className="dsh-docker-empty-icon"><IconAlert size={21} /></span>
179
+ <div className="dsh-docker-error-title">{title}</div>
180
+ {text !== undefined && <div className="dsh-docker-error-text">{text}</div>}
181
+ {onRetry !== undefined && <Button onClick={onRetry}>{retryLabel}</Button>}
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export function LoadingSkeleton({ rows = 3 }) {
187
+ return (
188
+ <div className="dsh-docker-skeleton" aria-label="Loading">
189
+ {Array.from({ length: rows }, (_, index) => <div key={index} className="dsh-docker-skel-line" />)}
190
+ </div>
191
+ )
192
+ }
193
+
194
+ export function SearchInput({ value, onChange, placeholder, label }) {
195
+ return (
196
+ <label className="dsh-docker-search">
197
+ <span className="dsh-docker-search-icon"><IconSearch size={14} /></span>
198
+ <input
199
+ className="dsh-docker-input"
200
+ value={value}
201
+ onChange={(event) => onChange(event.target.value)}
202
+ placeholder={placeholder}
203
+ aria-label={label ?? placeholder}
204
+ spellCheck="false"
205
+ />
206
+ </label>
207
+ )
208
+ }
209
+
210
+ export function CopyButton({ text, label, copiedLabel, className, children }) {
211
+ const [copied, setCopied] = useState(false)
212
+ const write = async () => {
213
+ try {
214
+ if (navigator.clipboard?.writeText !== undefined) await navigator.clipboard.writeText(text)
215
+ else {
216
+ const area = document.createElement('textarea')
217
+ area.value = text
218
+ area.style.position = 'fixed'
219
+ area.style.opacity = '0'
220
+ document.body.appendChild(area)
221
+ area.select()
222
+ document.execCommand('copy')
223
+ area.remove()
224
+ }
225
+ setCopied(true)
226
+ setTimeout(() => setCopied(false), 1500)
227
+ } catch {
228
+ setCopied(false)
229
+ }
230
+ }
231
+ return (
232
+ <Button onClick={write} className={className} title={label}>
233
+ {copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
234
+ {children ?? (copied ? copiedLabel : label)}
235
+ </Button>
236
+ )
237
+ }
238
+
239
+ export function Chips({ values, className, mono = false }) {
240
+ if (values.length === 0) return <span className="dsh-docker-muted">—</span>
241
+ return (
242
+ <span className={cx('dsh-docker-chips', className)}>
243
+ {values.map((value, index) => (
244
+ <span key={`${value}-${index}`} className={cx('dsh-docker-chip', mono && 'dsh-docker-chip-code')} title={value}>
245
+ {value}
246
+ </span>
247
+ ))}
248
+ </span>
249
+ )
250
+ }
251
+
252
+ /** @param {number} bytes */
253
+ export function formatBytes(bytes) {
254
+ if (!Number.isFinite(bytes) || bytes < 0) return '—'
255
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
256
+ let value = bytes
257
+ let unit = 0
258
+ while (value >= 1024 && unit < units.length - 1) {
259
+ value /= 1024
260
+ unit += 1
261
+ }
262
+ return `${unit === 0 ? Math.round(value) : value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`
263
+ }
264
+
265
+ /** @param {number} ms */
266
+ export function formatDuration(ms) {
267
+ if (!Number.isFinite(ms) || ms < 0) return '—'
268
+ const seconds = Math.floor(ms / 1000)
269
+ if (seconds < 60) return `${seconds}s`
270
+ const minutes = Math.floor(seconds / 60)
271
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
272
+ const hours = Math.floor(minutes / 60)
273
+ return `${hours}h ${minutes % 60}m`
274
+ }
275
+
276
+ /** @param {number | undefined} timestamp */
277
+ export function formatTime(timestamp) {
278
+ if (typeof timestamp !== 'number') return '—'
279
+ const date = new Date(timestamp)
280
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
281
+ }
282
+
283
+ /** @param {string} value */
284
+ export function shortId(value) {
285
+ if (typeof value !== 'string') return ''
286
+ return value.replace(/^sha256:/u, '').slice(0, 12)
287
+ }
288
+
289
+ /** @param {unknown} value */
290
+ export function displayValue(value) {
291
+ if (value === undefined || value === null || value === '') return '—'
292
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
293
+ return String(value)
294
+ }
295
+
296
+ /** @param {unknown} error */
297
+ export function errorMessage(error) {
298
+ if (error === undefined || error === null) return ''
299
+ if (error instanceof Error) return error.message
300
+ return String(error)
301
+ }