@spunto/design-system 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ "use client"
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react"
13
+
14
+ import { cn } from "../utils"
15
+ import { Spinner } from "./spinner"
16
+ import { Terminal, type TerminalHandle, type TerminalOptions, type TerminalViewportState } from "./terminal"
17
+
18
+ // Same surface as `Terminal` and `ImagePull` (#09090b), on purpose: an ImagePull
19
+ // stacked on a TerminalPanel has to read as *one* object — that's the build view.
20
+ // The bar is not a lighter "chrome" strip; it's the same surface, separated by a
21
+ // single hairline, so the eye sees one panel rather than a widget with a header.
22
+ const panelTheme = {
23
+ background: "#09090b",
24
+ border: "#27272a",
25
+ /** Hairline between the bar and the terminal — dimmer than the outer border. */
26
+ divider: "#1f1f23",
27
+ text: "#e4e4e7",
28
+ dim: "#a1a1aa",
29
+ faint: "#71717a",
30
+ // Status hues, taken from the Terminal's shared ANSI palette so a green dot up
31
+ // here and a green line down there are the same green.
32
+ live: "#4ade80",
33
+ pending: "#facc15",
34
+ error: "#f87171",
35
+ idle: "#52525b",
36
+ accent: "#ea5400",
37
+ } as const
38
+
39
+ /**
40
+ * Connection/lifecycle state of whatever feeds the terminal.
41
+ *
42
+ * The first four are the stream states every log panel already tracked by hand;
43
+ * the last three are build/job states (the worker page draws its own pulsing
44
+ * yellow/green/red dot for exactly this). They share one prop because they share
45
+ * one slot in the bar — a panel is never both connecting *and* building.
46
+ */
47
+ export type TerminalPanelStatus =
48
+ | "idle"
49
+ | "connecting"
50
+ | "live"
51
+ | "closed"
52
+ | "error"
53
+ | "running"
54
+ | "success"
55
+ | "failed"
56
+
57
+ export interface TerminalPanelProps {
58
+ /** Left of the bar. Sans-serif, the human name of what you're looking at. */
59
+ title?: ReactNode
60
+ /** Next to the title, mono and dimmed — `stdout / stderr`, a service name, a session. */
61
+ subtitle?: ReactNode
62
+ status?: TerminalPanelStatus
63
+ /** Overrides the default wording of `status` (which is French, like the rest of the DS). */
64
+ statusLabel?: string
65
+ /**
66
+ * Shows the reconnect button. This is the one action the DS knows about,
67
+ * because every panel already had its own copy of it.
68
+ */
69
+ onReconnect?: () => void
70
+ /** Free slot at the right of the bar — fullscreen, details, whatever the page owns. */
71
+ actions?: ReactNode
72
+ /**
73
+ * Replaces the terminal surface entirely: "aucun conteneur", "Pulling image…".
74
+ * The terminal is *unmounted* while it shows, so anything written meanwhile is
75
+ * dropped — which is the point: there is nothing to write to yet.
76
+ */
77
+ placeholder?: ReactNode
78
+ /** Puts a spinner above `placeholder` — the "…en cours" flavour of empty. */
79
+ loading?: boolean
80
+ /** Red band under the bar. Unlike `placeholder`, it keeps the log visible underneath. */
81
+ error?: string
82
+
83
+ // ── Bar options. All off by default: the bar starts sober, each page opts in. ──
84
+ /** Ctrl/⌘+F search bar over the scrollback (`@xterm/addon-search`). */
85
+ search?: boolean
86
+ /** Sticks to the tail and surfaces a "N nouvelles lignes ↓" pill once the user scrolls up. */
87
+ followTail?: boolean
88
+ /** Copies the whole buffer to the clipboard. */
89
+ copy?: boolean
90
+ /** Downloads the whole buffer as a `.log` file. */
91
+ download?: boolean
92
+ /** File name used by `download`. Defaults to the title/subtitle, else `terminal.log`. */
93
+ downloadFileName?: string
94
+
95
+ // ── Forwarded to the underlying `Terminal`. ──
96
+ fontSize?: number
97
+ scrollback?: number
98
+ onData?: (data: string) => void
99
+ onResize?: (size: { cols: number; rows: number }) => void
100
+ options?: TerminalOptions
101
+ className?: string
102
+ }
103
+
104
+ const STATUS_LABEL: Record<TerminalPanelStatus, string> = {
105
+ idle: "",
106
+ connecting: "connexion…",
107
+ live: "en direct",
108
+ closed: "déconnecté",
109
+ error: "erreur",
110
+ running: "en cours",
111
+ success: "terminé",
112
+ failed: "échec",
113
+ }
114
+
115
+ const STATUS_COLOR: Record<TerminalPanelStatus, string> = {
116
+ idle: panelTheme.idle,
117
+ connecting: panelTheme.pending,
118
+ live: panelTheme.live,
119
+ closed: panelTheme.idle,
120
+ error: panelTheme.error,
121
+ running: panelTheme.pending,
122
+ success: panelTheme.live,
123
+ failed: panelTheme.error,
124
+ }
125
+
126
+ /** The states where something is still happening — the dot pulses for those only. */
127
+ const PULSING: TerminalPanelStatus[] = ["connecting", "running"]
128
+
129
+ /**
130
+ * The frame around a `Terminal`: a bar on top, the terminal surface below, and
131
+ * the states (connecting, empty, loading, error) held by the component instead of
132
+ * re-improvised on every page.
133
+ *
134
+ * It exists because the frame *was* the duplicated part. The primitive is fine —
135
+ * every page kept it and rebuilt the same header around it: a title, a status
136
+ * dot, a reconnect button, an "aucun conteneur" placeholder, each with its own
137
+ * hardcoded hex and padding. Two pages, two looks, one intention.
138
+ *
139
+ * Two design decisions worth knowing before extending it:
140
+ *
141
+ * 1. **The bar is composable, not a fixed list.** Title/subtitle/status are the
142
+ * sober default; everything else (search, follow, copy, download) is opt-in,
143
+ * and `actions` is an open slot so a page can push its own buttons without
144
+ * the DS having to know about them.
145
+ * 2. **Connection status is a prop, not log content.** Panels used to write
146
+ * `[connection closed]` in ANSI straight into the buffer — which pollutes the
147
+ * log, travels with a copy/paste, and stacks up one line per reconnect since
148
+ * nothing ever erases it. `status` puts that in the bar, where it can be
149
+ * *replaced* instead of appended.
150
+ *
151
+ * The ref is the `TerminalHandle` of the terminal it wraps, so migrating a panel
152
+ * is a one-line swap: `<Terminal ref={r} …/>` → `<TerminalPanel ref={r} …/>`.
153
+ */
154
+ const TerminalPanel = forwardRef<TerminalHandle, TerminalPanelProps>(function TerminalPanel(
155
+ {
156
+ title,
157
+ subtitle,
158
+ status = "idle",
159
+ statusLabel,
160
+ onReconnect,
161
+ actions,
162
+ placeholder,
163
+ loading,
164
+ error,
165
+ search,
166
+ followTail,
167
+ copy,
168
+ download,
169
+ downloadFileName,
170
+ fontSize,
171
+ scrollback,
172
+ onData,
173
+ onResize,
174
+ options,
175
+ className,
176
+ },
177
+ ref
178
+ ) {
179
+ const termRef = useRef<TerminalHandle>(null)
180
+ const rootRef = useRef<HTMLDivElement>(null)
181
+
182
+ // Everything the ref exposes goes through the inner terminal — including while
183
+ // it's replaced by a placeholder, where the calls simply no-op.
184
+ useImperativeHandle(ref, () => ({
185
+ write: (data) => termRef.current?.write(data),
186
+ writeln: (data) => termRef.current?.writeln(data),
187
+ clear: () => termRef.current?.clear(),
188
+ focus: () => termRef.current?.focus(),
189
+ getContent: () => termRef.current?.getContent() ?? "",
190
+ scrollToBottom: () => termRef.current?.scrollToBottom(),
191
+ rowsBelow: () => termRef.current?.rowsBelow() ?? 0,
192
+ findNext: (query, opts) => termRef.current?.findNext(query, opts) ?? false,
193
+ findPrevious: (query, opts) => termRef.current?.findPrevious(query, opts) ?? false,
194
+ clearSearch: () => termRef.current?.clearSearch(),
195
+ }))
196
+
197
+ const searchUi = useSearch(termRef, !!search, rootRef)
198
+ const tail = useFollowTail(termRef, !!followTail)
199
+
200
+ const showPlaceholder = placeholder != null || loading
201
+ const hasBar = title != null || subtitle != null || status !== "idle" || !!actions || !!onReconnect || !!search || !!copy || !!download
202
+
203
+ return (
204
+ <div
205
+ ref={rootRef}
206
+ data-slot="terminal-panel"
207
+ className={cn(
208
+ // Square by default like `Terminal`/`ImagePull` — consumers add
209
+ // `rounded-*` (with the `overflow-hidden` already here) when they want it.
210
+ // `@container`: what has to fit is this box, not the viewport — a 380 px
211
+ // panel on a desktop is exactly as cramped as a phone.
212
+ "@container flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden border",
213
+ className
214
+ )}
215
+ style={{
216
+ backgroundColor: panelTheme.background,
217
+ borderColor: error ? "#7f1d1d" : panelTheme.border,
218
+ // Sets the default for everything inline (icons draw with currentColor) —
219
+ // without it they'd inherit the *page's* foreground, which is near-black
220
+ // in the light theme this dark panel sits in.
221
+ color: panelTheme.text,
222
+ }}
223
+ >
224
+ {hasBar && (
225
+ <div
226
+ className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs"
227
+ style={{ borderColor: panelTheme.divider }}
228
+ >
229
+ {status !== "idle" && <StatusChip status={status} label={statusLabel} />}
230
+ {title != null && (
231
+ <span className="min-w-0 truncate font-medium" style={{ color: panelTheme.text }}>
232
+ {title}
233
+ </span>
234
+ )}
235
+ {subtitle != null && (
236
+ <span className="hidden min-w-0 truncate font-mono @sm:inline" style={{ color: panelTheme.faint }}>
237
+ {subtitle}
238
+ </span>
239
+ )}
240
+
241
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
242
+ {actions}
243
+ {search && (
244
+ <BarButton label="Rechercher (Ctrl+F)" active={searchUi.open} onClick={searchUi.toggle}>
245
+ <SearchIcon />
246
+ </BarButton>
247
+ )}
248
+ {copy && <CopyButton getContent={() => termRef.current?.getContent() ?? ""} />}
249
+ {download && (
250
+ <BarButton
251
+ label="Télécharger le log"
252
+ onClick={() =>
253
+ downloadText(termRef.current?.getContent() ?? "", downloadFileName ?? defaultFileName(title, subtitle))
254
+ }
255
+ >
256
+ <DownloadIcon />
257
+ </BarButton>
258
+ )}
259
+ {onReconnect && (
260
+ <BarButton label="Reconnecter" onClick={onReconnect}>
261
+ <RefreshIcon />
262
+ </BarButton>
263
+ )}
264
+ </div>
265
+ </div>
266
+ )}
267
+
268
+ {search && searchUi.open && <SearchBar {...searchUi} />}
269
+
270
+ {error && (
271
+ <div
272
+ className="flex shrink-0 items-start gap-2 border-b px-3 py-2 font-mono text-xs leading-relaxed"
273
+ style={{ borderColor: "#7f1d1d", backgroundColor: "rgba(127,29,29,0.15)", color: panelTheme.error }}
274
+ >
275
+ <span className="shrink-0 pt-px">✕</span>
276
+ <span className="min-w-0 break-words">{error}</span>
277
+ </div>
278
+ )}
279
+
280
+ <div className="relative min-h-0 flex-1">
281
+ {showPlaceholder ? (
282
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-4 text-center">
283
+ {loading && <Spinner size="sm" style={{ color: panelTheme.faint }} />}
284
+ <p className="font-mono text-xs" style={{ color: panelTheme.faint }}>
285
+ {placeholder}
286
+ </p>
287
+ </div>
288
+ ) : (
289
+ <>
290
+ <Terminal
291
+ ref={termRef}
292
+ // The panel already draws the border and owns the corners.
293
+ className="border-0"
294
+ fontSize={fontSize}
295
+ scrollback={scrollback}
296
+ onData={onData}
297
+ onResize={onResize}
298
+ options={search ? { ...options, search: true } : options}
299
+ onViewportChange={followTail ? tail.onViewportChange : undefined}
300
+ onSearchResults={search ? searchUi.onResults : undefined}
301
+ />
302
+ {followTail && tail.detached && (
303
+ <button
304
+ type="button"
305
+ onClick={() => {
306
+ termRef.current?.scrollToBottom()
307
+ termRef.current?.focus()
308
+ }}
309
+ // Right-aligned but clear of xterm's scrollbar, and floating over
310
+ // the log rather than reserving a row — it's a transient hint.
311
+ className="absolute bottom-3 right-5 flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px] shadow-lg transition-colors"
312
+ style={{
313
+ backgroundColor: "#18181b",
314
+ borderColor: panelTheme.border,
315
+ color: tail.missed > 0 ? panelTheme.text : panelTheme.dim,
316
+ }}
317
+ >
318
+ {tail.missed > 0 ? `${tail.missed} nouvelle${tail.missed > 1 ? "s" : ""} ligne${tail.missed > 1 ? "s" : ""}` : "Revenir en bas"}
319
+ <ArrowDownIcon />
320
+ </button>
321
+ )}
322
+ </>
323
+ )}
324
+ </div>
325
+ </div>
326
+ )
327
+ })
328
+
329
+ /** Dot + word. The dot alone carries the state when the container is narrow. */
330
+ function StatusChip({ status, label }: { status: TerminalPanelStatus; label?: string }) {
331
+ const color = STATUS_COLOR[status]
332
+ const text = label ?? STATUS_LABEL[status]
333
+ return (
334
+ <span className="flex shrink-0 items-center gap-1.5" title={text}>
335
+ <span
336
+ // `motion-reduce`: the dot keeps its colour, it just stops breathing —
337
+ // the state is still readable without the animation.
338
+ className={cn("size-1.5 shrink-0 rounded-full", PULSING.includes(status) && "animate-pulse motion-reduce:animate-none")}
339
+ style={{ backgroundColor: color }}
340
+ />
341
+ <span className="hidden font-mono @md:inline" style={{ color }}>
342
+ {text}
343
+ </span>
344
+ </span>
345
+ )
346
+ }
347
+
348
+ function BarButton({
349
+ label,
350
+ active,
351
+ onClick,
352
+ children,
353
+ }: {
354
+ label: string
355
+ active?: boolean
356
+ onClick: () => void
357
+ children: ReactNode
358
+ }) {
359
+ return (
360
+ <button
361
+ type="button"
362
+ title={label}
363
+ aria-label={label}
364
+ onClick={onClick}
365
+ className="flex size-6 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/5"
366
+ style={{ color: active ? panelTheme.accent : panelTheme.faint }}
367
+ >
368
+ {children}
369
+ </button>
370
+ )
371
+ }
372
+
373
+ /** Copy with its own "done" beat — a clipboard write is invisible otherwise. */
374
+ function CopyButton({ getContent }: { getContent: () => string }) {
375
+ const [copied, setCopied] = useState(false)
376
+
377
+ useEffect(() => {
378
+ if (!copied) return
379
+ const id = setTimeout(() => setCopied(false), 1500)
380
+ return () => clearTimeout(id)
381
+ }, [copied])
382
+
383
+ return (
384
+ <BarButton
385
+ label={copied ? "Copié" : "Copier tout le log"}
386
+ onClick={async () => {
387
+ if (await copyText(getContent())) setCopied(true)
388
+ }}
389
+ >
390
+ <span style={copied ? { color: panelTheme.live } : undefined}>{copied ? <CheckIcon /> : <CopyIcon />}</span>
391
+ </BarButton>
392
+ )
393
+ }
394
+
395
+ interface SearchUi {
396
+ open: boolean
397
+ toggle: () => void
398
+ close: () => void
399
+ query: string
400
+ setQuery: (q: string) => void
401
+ results: { resultIndex: number; resultCount: number }
402
+ onResults: (r: { resultIndex: number; resultCount: number }) => void
403
+ next: () => void
404
+ previous: () => void
405
+ inputRef: React.RefObject<HTMLInputElement | null>
406
+ }
407
+
408
+ /** Its own row under the bar rather than an overlay — a log is dense, and floating
409
+ * search boxes end up hiding the match they just found. */
410
+ function SearchBar({ query, setQuery, results, next, previous, close, inputRef }: SearchUi) {
411
+ const empty = query.length > 0 && results.resultCount === 0
412
+ return (
413
+ <div
414
+ className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
415
+ style={{ borderColor: panelTheme.divider, backgroundColor: "#0c0c10", color: panelTheme.faint }}
416
+ >
417
+ <SearchIcon />
418
+ <input
419
+ ref={inputRef}
420
+ value={query}
421
+ onChange={(e) => setQuery(e.target.value)}
422
+ onKeyDown={(e) => {
423
+ if (e.key === "Enter") {
424
+ e.preventDefault()
425
+ if (e.shiftKey) previous()
426
+ else next()
427
+ } else if (e.key === "Escape") {
428
+ e.preventDefault()
429
+ close()
430
+ }
431
+ }}
432
+ placeholder="Rechercher dans le log…"
433
+ spellCheck={false}
434
+ className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:opacity-60"
435
+ style={{ color: empty ? panelTheme.error : panelTheme.text }}
436
+ />
437
+ <span className="shrink-0 font-mono text-[11px] tabular-nums" style={{ color: empty ? panelTheme.error : panelTheme.faint }}>
438
+ {query.length === 0 ? "" : results.resultCount === 0 ? "aucun résultat" : `${results.resultIndex + 1}/${results.resultCount}`}
439
+ </span>
440
+ <BarButton label="Résultat précédent (Maj+Entrée)" onClick={previous}>
441
+ <ChevronIcon up />
442
+ </BarButton>
443
+ <BarButton label="Résultat suivant (Entrée)" onClick={next}>
444
+ <ChevronIcon />
445
+ </BarButton>
446
+ <BarButton label="Fermer la recherche (Échap)" onClick={close}>
447
+ <CloseIcon />
448
+ </BarButton>
449
+ </div>
450
+ )
451
+ }
452
+
453
+ /**
454
+ * Search state + the Ctrl/⌘+F binding. The shortcut is bound on the panel root in
455
+ * the capture phase: xterm owns the keyboard once focused, so waiting for the
456
+ * event to bubble means never seeing it.
457
+ */
458
+ function useSearch(termRef: React.RefObject<TerminalHandle | null>, enabled: boolean, rootRef: React.RefObject<HTMLDivElement | null>): SearchUi {
459
+ const [open, setOpen] = useState(false)
460
+ const [query, setQueryState] = useState("")
461
+ const [results, setResults] = useState({ resultIndex: -1, resultCount: 0 })
462
+ const inputRef = useRef<HTMLInputElement>(null)
463
+
464
+ const close = useCallback(() => {
465
+ setOpen(false)
466
+ setQueryState("")
467
+ setResults({ resultIndex: -1, resultCount: 0 })
468
+ termRef.current?.clearSearch()
469
+ termRef.current?.focus()
470
+ }, [termRef])
471
+
472
+ const toggle = useCallback(() => {
473
+ setOpen((v) => {
474
+ if (v) {
475
+ termRef.current?.clearSearch()
476
+ return false
477
+ }
478
+ return true
479
+ })
480
+ }, [termRef])
481
+
482
+ useEffect(() => {
483
+ if (open) inputRef.current?.focus()
484
+ }, [open])
485
+
486
+ useEffect(() => {
487
+ const root = rootRef.current
488
+ if (!enabled || !root) return
489
+ function onKeyDown(e: KeyboardEvent) {
490
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") {
491
+ e.preventDefault()
492
+ e.stopPropagation()
493
+ setOpen(true)
494
+ // Already open: re-select so a second Ctrl+F starts a new query.
495
+ inputRef.current?.select()
496
+ }
497
+ }
498
+ root.addEventListener("keydown", onKeyDown, true)
499
+ return () => root.removeEventListener("keydown", onKeyDown, true)
500
+ }, [enabled, rootRef])
501
+
502
+ const setQuery = useCallback(
503
+ (q: string) => {
504
+ setQueryState(q)
505
+ if (q) {
506
+ // `incremental` keeps the match anchored where you are while you type,
507
+ // instead of walking forward one match per keystroke.
508
+ termRef.current?.findNext(q, { incremental: true })
509
+ } else {
510
+ termRef.current?.clearSearch()
511
+ setResults({ resultIndex: -1, resultCount: 0 })
512
+ }
513
+ },
514
+ [termRef]
515
+ )
516
+
517
+ return {
518
+ open,
519
+ toggle,
520
+ close,
521
+ query,
522
+ setQuery,
523
+ results,
524
+ onResults: setResults,
525
+ next: () => query && termRef.current?.findNext(query),
526
+ previous: () => query && termRef.current?.findPrevious(query),
527
+ inputRef,
528
+ }
529
+ }
530
+
531
+ /**
532
+ * "Am I still at the tail, and how much have I missed?"
533
+ *
534
+ * The counter is *lines since the viewport last touched the bottom* — the reason
535
+ * flag from the terminal is what makes that computable: output events increment,
536
+ * and any moment spent at the bottom (scrolled back, or never left) resets. A busy
537
+ * stream fires this per line, so the count accumulates in a ref and only lands in
538
+ * state once per frame.
539
+ */
540
+ function useFollowTail(termRef: React.RefObject<TerminalHandle | null>, enabled: boolean) {
541
+ const [state, setState] = useState({ detached: false, missed: 0 })
542
+ const pending = useRef({ detached: false, missed: 0 })
543
+ const frame = useRef<number | null>(null)
544
+
545
+ useEffect(
546
+ () => () => {
547
+ if (frame.current != null) cancelAnimationFrame(frame.current)
548
+ },
549
+ []
550
+ )
551
+
552
+ const onViewportChange = useCallback((v: TerminalViewportState) => {
553
+ const p = pending.current
554
+ if (v.rowsBelow === 0) p.missed = 0
555
+ else if (v.reason === "output") p.missed += 1
556
+ p.detached = v.rowsBelow > 0
557
+
558
+ if (frame.current != null) return
559
+ frame.current = requestAnimationFrame(() => {
560
+ frame.current = null
561
+ setState((prev) =>
562
+ prev.detached === pending.current.detached && prev.missed === pending.current.missed
563
+ ? prev
564
+ : { ...pending.current }
565
+ )
566
+ })
567
+ }, [])
568
+
569
+ return useMemo(
570
+ () => ({ ...state, onViewportChange: enabled ? onViewportChange : undefined }),
571
+ [state, enabled, onViewportChange]
572
+ )
573
+ }
574
+
575
+ /** Clipboard API, with the `execCommand` fallback for non-secure contexts. */
576
+ async function copyText(text: string): Promise<boolean> {
577
+ try {
578
+ await navigator.clipboard.writeText(text)
579
+ return true
580
+ } catch {
581
+ try {
582
+ const area = document.createElement("textarea")
583
+ area.value = text
584
+ area.style.position = "fixed"
585
+ area.style.opacity = "0"
586
+ document.body.appendChild(area)
587
+ area.select()
588
+ const ok = document.execCommand("copy")
589
+ document.body.removeChild(area)
590
+ return ok
591
+ } catch {
592
+ return false
593
+ }
594
+ }
595
+ }
596
+
597
+ function downloadText(text: string, fileName: string) {
598
+ const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }))
599
+ const link = document.createElement("a")
600
+ link.href = url
601
+ link.download = fileName
602
+ link.click()
603
+ URL.revokeObjectURL(url)
604
+ }
605
+
606
+ /** `service api — stdout / stderr` → `service-api-stdout-stderr.log`. */
607
+ function defaultFileName(title: ReactNode, subtitle: ReactNode): string {
608
+ const parts = [title, subtitle].filter((p) => typeof p === "string") as string[]
609
+ const slug = parts
610
+ .join("-")
611
+ .toLowerCase()
612
+ .normalize("NFD")
613
+ .replace(/[\u0300-\u036f]/g, "")
614
+ .replace(/[^a-z0-9]+/g, "-")
615
+ .replace(/^-|-$/g, "")
616
+ return `${slug || "terminal"}.log`
617
+ }
618
+
619
+ // ── Icons. Inlined like `ImagePull`'s: the design system doesn't take an icon
620
+ // dependency, and these five never leave this file. ──
621
+
622
+ function SearchIcon() {
623
+ return (
624
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
625
+ <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
626
+ <path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
627
+ </svg>
628
+ )
629
+ }
630
+
631
+ function CopyIcon() {
632
+ return (
633
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
634
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
635
+ <path d="M10.5 2.5h-8v8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
636
+ </svg>
637
+ )
638
+ }
639
+
640
+ function CheckIcon() {
641
+ return (
642
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
643
+ <path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
644
+ </svg>
645
+ )
646
+ }
647
+
648
+ function DownloadIcon() {
649
+ return (
650
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
651
+ <path d="M8 2.5v7m0 0L5 6.7M8 9.5l3-2.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
652
+ <path d="M2.8 12.5h10.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
653
+ </svg>
654
+ )
655
+ }
656
+
657
+ function RefreshIcon() {
658
+ return (
659
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
660
+ <path
661
+ d="M13 8a5 5 0 1 1-1.6-3.7"
662
+ stroke="currentColor"
663
+ strokeWidth="1.5"
664
+ strokeLinecap="round"
665
+ />
666
+ <path d="M13.2 2v3h-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
667
+ </svg>
668
+ )
669
+ }
670
+
671
+ function ChevronIcon({ up }: { up?: boolean }) {
672
+ return (
673
+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5 shrink-0", up && "rotate-180")} aria-hidden>
674
+ <path d="m4 6.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
675
+ </svg>
676
+ )
677
+ }
678
+
679
+ function ArrowDownIcon() {
680
+ return (
681
+ <svg viewBox="0 0 16 16" fill="none" className="size-3 shrink-0" aria-hidden>
682
+ <path d="M8 3v9m0 0 3.5-3.5M8 12 4.5 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
683
+ </svg>
684
+ )
685
+ }
686
+
687
+ function CloseIcon() {
688
+ return (
689
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
690
+ <path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
691
+ </svg>
692
+ )
693
+ }
694
+
695
+ export { TerminalPanel }