@spunto/design-system 0.9.0 → 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.
- package/README.md +9 -35
- package/package.json +2 -6
- package/src/components/terminal-panel.tsx +695 -0
- package/src/components/terminal.tsx +152 -1
- package/src/index.ts +13 -6
- package/src/utils.ts +0 -23
- package/src/components/workers/creator-avatar.tsx +0 -38
- package/src/components/workers/git-branch-chips.tsx +0 -120
- package/src/components/workers/index.ts +0 -35
- package/src/components/workers/resource-bars.tsx +0 -100
- package/src/components/workers/setup-progress.tsx +0 -130
- package/src/components/workers/tag-chips.tsx +0 -132
- package/src/components/workers/tag-color.ts +0 -28
- package/src/components/workers/types.ts +0 -44
- package/src/components/workers/worker-card.tsx +0 -305
- package/src/components/workers/worker-status.tsx +0 -181
|
@@ -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 }
|