@spunto/design-system 0.9.1 → 0.9.3
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 +10 -2
- package/package.json +2 -1
- package/src/components/terminal-panel.tsx +695 -0
- package/src/components/terminal.tsx +152 -1
- package/src/components/workers/git-branch-chips.tsx +17 -9
- package/src/components/workers/resource-bars.tsx +8 -4
- package/src/components/workers/worker-card.tsx +76 -40
- package/src/index.ts +12 -1
package/README.md
CHANGED
|
@@ -54,7 +54,15 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
54
54
|
- _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
|
|
55
55
|
xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
|
|
56
56
|
bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
|
|
57
|
-
ref handle. Opt-in addons through `options` (
|
|
57
|
+
ref handle. Opt-in addons through `options` (`webLinks` for clickable URLs, `search`
|
|
58
|
+
for the find API). The handle also reads the buffer back (`getContent`, for copy/
|
|
59
|
+
download) and reports viewport movement (`onViewportChange`, for tail following).
|
|
60
|
+
- _TerminalPanel_ — `TerminalPanel` (+`TerminalPanelStatus`), the **frame** around a
|
|
61
|
+
`Terminal`: a composable bar (title, mono subtitle, status dot, reconnect, free
|
|
62
|
+
`actions` slot) and the states pages used to re-improvise — `placeholder`/`loading`
|
|
63
|
+
instead of an ad-hoc empty box, `status` instead of `[connection closed]` written in
|
|
64
|
+
ANSI into the log. Opt-in bar tools, all off by default: `search`, `followTail`,
|
|
65
|
+
`copy`, `download`. Its ref *is* the wrapped `TerminalHandle`.
|
|
58
66
|
- _ImagePull_ — `ImagePull` (+`PullLayer`, `PullLayerStatus`, `ImagePullState`), a Docker
|
|
59
67
|
image pull drawn as a real UI instead of ANSI art redrawn in a terminal: **one** overall
|
|
60
68
|
bar whose colour follows the phase (flame downloading → amber unpacking over it → solid
|
|
@@ -167,7 +175,7 @@ automatically (see `showcase/vite-plugin-llms.ts`).
|
|
|
167
175
|
## Peer requirements
|
|
168
176
|
|
|
169
177
|
- React 19+, and a **Tailwind CSS v4** pipeline in the consuming app.
|
|
170
|
-
- `lucide-react` is a **regular dependency** (since `0.9.
|
|
178
|
+
- `lucide-react` is a **regular dependency** (since `0.9.3`): the worker
|
|
171
179
|
components use ~15 icons, and inlining them as SVG or passing them all as props
|
|
172
180
|
would have been worse than the dependency. It's tree-shakable and both apps
|
|
173
181
|
already ship it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@xterm/addon-fit": "^0.11.0",
|
|
56
|
+
"@xterm/addon-search": "^0.16.0",
|
|
56
57
|
"@xterm/addon-web-links": "^0.12.0",
|
|
57
58
|
"@xterm/xterm": "^6.0.0",
|
|
58
59
|
"class-variance-authority": "^0.7.1",
|
|
@@ -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 }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
|
|
4
4
|
import type { Terminal as XTerm } from "@xterm/xterm"
|
|
5
|
+
import type { SearchAddon } from "@xterm/addon-search"
|
|
5
6
|
// xterm.js's own stylesheet — hides its input-capture textarea and glyph
|
|
6
7
|
// measurement helpers. A JS-side import (not a CSS `@import`) so it loads
|
|
7
8
|
// regardless of the consuming app's stylesheet pipeline/ordering.
|
|
@@ -34,17 +35,55 @@ const terminalTheme = {
|
|
|
34
35
|
brightWhite: "#f4f4f5",
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
export interface TerminalSearchOptions {
|
|
39
|
+
caseSensitive?: boolean
|
|
40
|
+
wholeWord?: boolean
|
|
41
|
+
regex?: boolean
|
|
42
|
+
/** Re-runs from the current match rather than the next one — for search-as-you-type. */
|
|
43
|
+
incremental?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What `onViewportChange` reports. See that prop for why both fields are needed. */
|
|
47
|
+
export interface TerminalViewportState {
|
|
48
|
+
/** Rows between the bottom of the viewport and the end of the buffer. 0 = stuck to the tail. */
|
|
49
|
+
rowsBelow: number
|
|
50
|
+
/** `"output"` = a new line was printed · `"scroll"` = the viewport itself moved. */
|
|
51
|
+
reason: "output" | "scroll"
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
export interface TerminalHandle {
|
|
38
55
|
/** Writes raw bytes/text (ANSI sequences included) to the terminal. */
|
|
39
56
|
write: (data: string | Uint8Array) => void
|
|
40
57
|
writeln: (data: string) => void
|
|
41
58
|
clear: () => void
|
|
42
59
|
focus: () => void
|
|
60
|
+
/**
|
|
61
|
+
* The whole scrollback as plain text — ANSI stripped, rows that xterm split at
|
|
62
|
+
* the viewport width rejoined into the single line they were written as. This
|
|
63
|
+
* is what "copy everything" / "download the log" need, and neither can be done
|
|
64
|
+
* from outside: only xterm holds the buffer.
|
|
65
|
+
*/
|
|
66
|
+
getContent: () => string
|
|
67
|
+
/** Jumps the viewport back to the tail of the buffer (the "follow" button). */
|
|
68
|
+
scrollToBottom: () => void
|
|
69
|
+
/** Same number as `TerminalViewportState.rowsBelow`, polled instead of pushed. */
|
|
70
|
+
rowsBelow: () => number
|
|
71
|
+
/**
|
|
72
|
+
* Search, delegated to `@xterm/addon-search`. No-ops (returns false) unless
|
|
73
|
+
* `options.search` was set at mount — the addon isn't loaded otherwise.
|
|
74
|
+
* Match highlighting/colors are handled inside; callers only pass the query.
|
|
75
|
+
*/
|
|
76
|
+
findNext: (query: string, options?: TerminalSearchOptions) => boolean
|
|
77
|
+
findPrevious: (query: string, options?: TerminalSearchOptions) => boolean
|
|
78
|
+
/** Drops the highlights — call it when the search UI closes. */
|
|
79
|
+
clearSearch: () => void
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
export interface TerminalOptions {
|
|
46
83
|
/** Turns URLs in terminal output into clickable links (`@xterm/addon-web-links`). */
|
|
47
84
|
webLinks?: boolean
|
|
85
|
+
/** Loads `@xterm/addon-search`, which powers the `findNext`/`findPrevious` handle. */
|
|
86
|
+
search?: boolean
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
export interface TerminalProps {
|
|
@@ -63,6 +102,27 @@ export interface TerminalProps {
|
|
|
63
102
|
* props here.
|
|
64
103
|
*/
|
|
65
104
|
options?: TerminalOptions
|
|
105
|
+
/**
|
|
106
|
+
* Fires whenever the buffer grows or the viewport moves. Exists for one job:
|
|
107
|
+
* letting a wrapper (`TerminalPanel`) answer "is the user still watching the
|
|
108
|
+
* tail, and how much have they missed?" — xterm never scrolls the user down by
|
|
109
|
+
* force, but it also never tells them lines are piling up below.
|
|
110
|
+
*
|
|
111
|
+
* `reason` is what makes the count possible: an `"output"` event means one more
|
|
112
|
+
* missed line, a `"scroll"` event means the user moved on purpose and the count
|
|
113
|
+
* should re-baseline. It fires per line feed on a busy stream, so coalesce
|
|
114
|
+
* (rAF/ref) rather than calling `setState` straight from it.
|
|
115
|
+
*/
|
|
116
|
+
onViewportChange?: (state: TerminalViewportState) => void
|
|
117
|
+
/**
|
|
118
|
+
* New grid size after a fit — i.e. after the container resized. An interactive
|
|
119
|
+
* consumer has to forward this to whatever is on the other end (a PTY sizes its
|
|
120
|
+
* output to the client's grid), and `fit()` happens in here, so nowhere else can
|
|
121
|
+
* observe it. Also fires once at mount with the initial size.
|
|
122
|
+
*/
|
|
123
|
+
onResize?: (size: { cols: number; rows: number }) => void
|
|
124
|
+
/** Match count/position of the running search — only fires when `options.search` is on. */
|
|
125
|
+
onSearchResults?: (results: { resultIndex: number; resultCount: number }) => void
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
/**
|
|
@@ -72,12 +132,22 @@ export interface TerminalProps {
|
|
|
72
132
|
* plug in on top.
|
|
73
133
|
*/
|
|
74
134
|
const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
75
|
-
{ className, fontSize = 13, scrollback = 5000, onData, options },
|
|
135
|
+
{ className, fontSize = 13, scrollback = 5000, onData, options, onViewportChange, onResize, onSearchResults },
|
|
76
136
|
ref
|
|
77
137
|
) {
|
|
78
138
|
const outerRef = useRef<HTMLDivElement>(null)
|
|
79
139
|
const mountRef = useRef<HTMLDivElement>(null)
|
|
80
140
|
const termRef = useRef<XTerm | null>(null)
|
|
141
|
+
const searchRef = useRef<SearchAddon | null>(null)
|
|
142
|
+
// xterm's event handlers are wired once at mount, but these two callbacks come
|
|
143
|
+
// from a consumer that re-renders — read them through refs so a new closure
|
|
144
|
+
// doesn't require tearing the terminal down.
|
|
145
|
+
const viewportCbRef = useRef(onViewportChange)
|
|
146
|
+
const resizeCbRef = useRef(onResize)
|
|
147
|
+
const searchCbRef = useRef(onSearchResults)
|
|
148
|
+
viewportCbRef.current = onViewportChange
|
|
149
|
+
resizeCbRef.current = onResize
|
|
150
|
+
searchCbRef.current = onSearchResults
|
|
81
151
|
// xterm mounts asynchronously (dynamic import); a consumer wiring up a
|
|
82
152
|
// transport (WebSocket, SSE...) in its own effect can easily win that race
|
|
83
153
|
// and call write() before termRef exists. Queue those calls instead of
|
|
@@ -97,6 +167,12 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
97
167
|
},
|
|
98
168
|
clear: () => termRef.current?.clear(),
|
|
99
169
|
focus: () => termRef.current?.focus(),
|
|
170
|
+
getContent: () => (termRef.current ? bufferToText(termRef.current) : ""),
|
|
171
|
+
scrollToBottom: () => termRef.current?.scrollToBottom(),
|
|
172
|
+
rowsBelow: () => (termRef.current ? rowsBelowViewport(termRef.current) : 0),
|
|
173
|
+
findNext: (query, opts) => searchRef.current?.findNext(query, searchOptions(opts)) ?? false,
|
|
174
|
+
findPrevious: (query, opts) => searchRef.current?.findPrevious(query, searchOptions(opts)) ?? false,
|
|
175
|
+
clearSearch: () => searchRef.current?.clearDecorations(),
|
|
100
176
|
}),
|
|
101
177
|
[]
|
|
102
178
|
)
|
|
@@ -133,6 +209,9 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
133
209
|
lineHeight: 1.2,
|
|
134
210
|
scrollback,
|
|
135
211
|
theme: terminalTheme,
|
|
212
|
+
// Only the search addon needs it (its match highlighting goes through
|
|
213
|
+
// the decoration API), so it stays off for everyone else.
|
|
214
|
+
allowProposedApi: !!options?.search,
|
|
136
215
|
})
|
|
137
216
|
|
|
138
217
|
const fitAddon = new FitAddon()
|
|
@@ -141,6 +220,13 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
141
220
|
const { WebLinksAddon } = await import("@xterm/addon-web-links")
|
|
142
221
|
term.loadAddon(new WebLinksAddon())
|
|
143
222
|
}
|
|
223
|
+
if (options?.search) {
|
|
224
|
+
const { SearchAddon } = await import("@xterm/addon-search")
|
|
225
|
+
const searchAddon = new SearchAddon()
|
|
226
|
+
term.loadAddon(searchAddon)
|
|
227
|
+
searchAddon.onDidChangeResults((results) => searchCbRef.current?.(results))
|
|
228
|
+
searchRef.current = searchAddon
|
|
229
|
+
}
|
|
144
230
|
// FitAddon computes available space from *this* element's parent minus
|
|
145
231
|
// this element's own padding — it has no idea about an outer wrapper's
|
|
146
232
|
// padding/border. Mounting on a padding-less, border-less inner div (see
|
|
@@ -159,6 +245,16 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
159
245
|
|
|
160
246
|
if (onData) term.onData(onData)
|
|
161
247
|
|
|
248
|
+
// Two events rather than one because they mean opposite things to a tail
|
|
249
|
+
// follower: a line feed is output the user may be missing, a scroll is the
|
|
250
|
+
// user deciding where to look.
|
|
251
|
+
term.onLineFeed(() => viewportCbRef.current?.({ rowsBelow: rowsBelowViewport(term), reason: "output" }))
|
|
252
|
+
term.onScroll(() => viewportCbRef.current?.({ rowsBelow: rowsBelowViewport(term), reason: "scroll" }))
|
|
253
|
+
term.onResize((size) => resizeCbRef.current?.(size))
|
|
254
|
+
// xterm only emits onResize on *changes*, and the first fit() above already
|
|
255
|
+
// happened — so announce the initial grid explicitly.
|
|
256
|
+
resizeCbRef.current?.({ cols: term.cols, rows: term.rows })
|
|
257
|
+
|
|
162
258
|
resizeObserver = new ResizeObserver(() => fitAddon.fit())
|
|
163
259
|
resizeObserver.observe(mountRef.current)
|
|
164
260
|
}
|
|
@@ -170,6 +266,7 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
170
266
|
resizeObserver?.disconnect()
|
|
171
267
|
termRef.current?.dispose()
|
|
172
268
|
termRef.current = null
|
|
269
|
+
searchRef.current = null
|
|
173
270
|
}
|
|
174
271
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
272
|
}, [])
|
|
@@ -201,4 +298,58 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
|
201
298
|
)
|
|
202
299
|
})
|
|
203
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Rows between the bottom of the viewport and the end of the buffer. `baseY` is
|
|
303
|
+
* where the tail starts, `viewportY` where the user is looking — equal means
|
|
304
|
+
* "stuck to the bottom", which is the state a follow-tail affordance cares about.
|
|
305
|
+
*/
|
|
306
|
+
function rowsBelowViewport(term: XTerm): number {
|
|
307
|
+
const buffer = term.buffer.active
|
|
308
|
+
return Math.max(0, buffer.baseY - buffer.viewportY)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The buffer as plain text. Two things xterm won't do for you:
|
|
313
|
+
*
|
|
314
|
+
* - **Rejoin wrapped rows.** A 300-char line is stored as three rows once the
|
|
315
|
+
* viewport is 100 columns wide; copying it as three lines would make the
|
|
316
|
+
* exported log depend on the window width it was read at.
|
|
317
|
+
* - **Drop the empty tail.** The scrollback is pre-allocated, so the last rows
|
|
318
|
+
* are blank filler, not log content.
|
|
319
|
+
*/
|
|
320
|
+
function bufferToText(term: XTerm): string {
|
|
321
|
+
const buffer = term.buffer.active
|
|
322
|
+
const lines: string[] = []
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
325
|
+
const line = buffer.getLine(i)
|
|
326
|
+
if (!line) continue
|
|
327
|
+
const text = line.translateToString(true)
|
|
328
|
+
if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text
|
|
329
|
+
else lines.push(text)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
|
333
|
+
return lines.join("\n")
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Match colors live here, not at the call site: search is a design-system
|
|
338
|
+
* affordance and every terminal in the product should highlight the same way —
|
|
339
|
+
* zinc for the other matches, flame for the one you're on.
|
|
340
|
+
*/
|
|
341
|
+
function searchOptions(options?: TerminalSearchOptions) {
|
|
342
|
+
return {
|
|
343
|
+
...options,
|
|
344
|
+
decorations: {
|
|
345
|
+
matchBackground: "#3f3f46",
|
|
346
|
+
matchBorder: "#71717a",
|
|
347
|
+
matchOverviewRuler: "#a1a1aa",
|
|
348
|
+
activeMatchBackground: "#ea5400",
|
|
349
|
+
activeMatchBorder: "#fb923c",
|
|
350
|
+
activeMatchColorOverviewRuler: "#ea5400",
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
204
355
|
export { Terminal }
|
|
@@ -40,7 +40,10 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
|
|
|
40
40
|
if (!repos.length) return null
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
|
-
|
|
43
|
+
// `@container` so the branch cap below reads *this row's* width: the same
|
|
44
|
+
// chips sit in a 600 px card on a desktop and in a 290 px one on a phone,
|
|
45
|
+
// and a viewport media query can't tell those apart.
|
|
46
|
+
<div data-slot="git-branch-chips" className={cn("@container flex flex-wrap gap-1.5", className)}>
|
|
44
47
|
{repos.map((repo) => {
|
|
45
48
|
const href = repoHref?.(repo)
|
|
46
49
|
const Tag = href ? "a" : "span"
|
|
@@ -49,17 +52,22 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
|
|
|
49
52
|
key={repo.path}
|
|
50
53
|
{...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
51
54
|
title={repo.path}
|
|
55
|
+
// `max-w-full` is what keeps a chip inside the card: without it a
|
|
56
|
+
// long "repo · branch" is a single unbreakable row that runs under
|
|
57
|
+
// the card's rounded edge and gets clipped.
|
|
52
58
|
className={cn(
|
|
53
|
-
"inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
|
|
59
|
+
"inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
|
|
54
60
|
href && "transition-colors hover:border-border/80 hover:bg-muted/70"
|
|
55
61
|
)}
|
|
56
62
|
>
|
|
57
|
-
<span className="font-medium text-foreground/70">{repoName(repo)}</span>
|
|
58
|
-
<span className="mx-0.5 text-muted-foreground/40">·</span>
|
|
63
|
+
<span className="shrink-0 font-medium text-foreground/70">{repoName(repo)}</span>
|
|
64
|
+
<span className="mx-0.5 shrink-0 text-muted-foreground/40">·</span>
|
|
59
65
|
<GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
|
|
60
|
-
|
|
66
|
+
{/* The branch is the only part allowed to give up room — the repo
|
|
67
|
+
name and the counters are short and carry more meaning. */}
|
|
68
|
+
<span className="min-w-0 max-w-[6rem] truncate @min-[20rem]:max-w-[120px]">{repo.branch || "—"}</span>
|
|
61
69
|
<RepoCounters repo={repo} />
|
|
62
|
-
{href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 opacity-40" />}
|
|
70
|
+
{href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 shrink-0 opacity-40" />}
|
|
63
71
|
</Tag>
|
|
64
72
|
)
|
|
65
73
|
})}
|
|
@@ -71,15 +79,15 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
|
|
|
71
79
|
function RepoCounters({ repo }: { repo: GitRepoStatus }) {
|
|
72
80
|
return (
|
|
73
81
|
<>
|
|
74
|
-
{!!repo.modified && <span className="ml-0.5 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
|
|
82
|
+
{!!repo.modified && <span className="ml-0.5 shrink-0 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
|
|
75
83
|
{!!repo.ahead && (
|
|
76
|
-
<span className="ml-0.5 inline-flex items-center text-amber-500 dark:text-amber-400">
|
|
84
|
+
<span className="ml-0.5 inline-flex shrink-0 items-center text-amber-500 dark:text-amber-400">
|
|
77
85
|
<ArrowUpIcon className="h-2.5 w-2.5" />
|
|
78
86
|
{repo.ahead}
|
|
79
87
|
</span>
|
|
80
88
|
)}
|
|
81
89
|
{!!repo.behind && (
|
|
82
|
-
<span className="ml-0.5 inline-flex items-center text-muted-foreground/70">
|
|
90
|
+
<span className="ml-0.5 inline-flex shrink-0 items-center text-muted-foreground/70">
|
|
83
91
|
<ArrowDownIcon className="h-2.5 w-2.5" />
|
|
84
92
|
{repo.behind}
|
|
85
93
|
</span>
|
|
@@ -33,7 +33,11 @@ export function ResourceBars({ stats, compact = false, className }: ResourceBars
|
|
|
33
33
|
<div
|
|
34
34
|
data-slot="resource-bars"
|
|
35
35
|
className={cn(
|
|
36
|
-
|
|
36
|
+
// Side by side as soon as there's room for it, stacked below ~240 px —
|
|
37
|
+
// under that the label and its figure end up touching. Container query,
|
|
38
|
+
// not a media query: these bars live in a card, a table cell and a
|
|
39
|
+
// sidebar, and only their own width tells them what to do.
|
|
40
|
+
"@container grid grid-cols-1 gap-3 @min-[15rem]:grid-cols-2",
|
|
37
41
|
!compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
|
|
38
42
|
className
|
|
39
43
|
)}
|
|
@@ -82,12 +86,12 @@ function Metric({
|
|
|
82
86
|
}) {
|
|
83
87
|
return (
|
|
84
88
|
<div>
|
|
85
|
-
<div className="mb-1.5 flex items-center justify-between">
|
|
86
|
-
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
|
89
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
90
|
+
<span className="flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
|
|
87
91
|
{icon}
|
|
88
92
|
{label}
|
|
89
93
|
</span>
|
|
90
|
-
<span className={cn("font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
|
|
94
|
+
<span className={cn("shrink-0 font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
|
|
91
95
|
</div>
|
|
92
96
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
93
97
|
<div
|
|
@@ -136,7 +136,14 @@ export function WorkerCard({
|
|
|
136
136
|
return (
|
|
137
137
|
<div
|
|
138
138
|
data-slot="worker-card"
|
|
139
|
-
|
|
139
|
+
// `@container`: every responsive rule below is a container query, never a
|
|
140
|
+
// viewport one. The same card is a half-width tile on a desktop grid and a
|
|
141
|
+
// full-width row on a phone — and a 290 px card in a sidebar on a 27"
|
|
142
|
+
// screen is exactly as cramped as a phone.
|
|
143
|
+
className={cn(
|
|
144
|
+
"@container overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm",
|
|
145
|
+
className
|
|
146
|
+
)}
|
|
140
147
|
>
|
|
141
148
|
{/* ── Header : état, nom, pills, tags, méta ── */}
|
|
142
149
|
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
|
|
@@ -145,7 +152,9 @@ export function WorkerCard({
|
|
|
145
152
|
|
|
146
153
|
<div className="min-w-0 flex-1">
|
|
147
154
|
<div className="flex flex-wrap items-center gap-2">
|
|
148
|
-
|
|
155
|
+
{/* A worker name is one unbreakable token ("spunto-fix-tunnel-42"):
|
|
156
|
+
without the cap it pushes the card wider than its column. */}
|
|
157
|
+
<span className="max-w-full truncate text-sm leading-none font-semibold">{title}</span>
|
|
149
158
|
<WorkerStatusPill status={status} />
|
|
150
159
|
{worker.projectVersion != null && (
|
|
151
160
|
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
|
|
@@ -155,46 +164,37 @@ export function WorkerCard({
|
|
|
155
164
|
<TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
|
|
156
165
|
</div>
|
|
157
166
|
|
|
158
|
-
|
|
167
|
+
{/* Meta line. Each item carries its own leading separator instead of
|
|
168
|
+
the separators being siblings: this row wraps onto two lines as
|
|
169
|
+
soon as the card is narrow, and a sibling "·" then ends up
|
|
170
|
+
stranded at the end of a line, pointing at nothing. Attached to
|
|
171
|
+
the item, the worst case is a line that *starts* with "·", which
|
|
172
|
+
reads as a continuation. */}
|
|
173
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
159
174
|
{creator && (
|
|
160
|
-
<
|
|
175
|
+
<MetaItem first>
|
|
161
176
|
<CreatorAvatar creator={creator} />
|
|
162
|
-
<span className="text-
|
|
163
|
-
</
|
|
177
|
+
<span className="text-muted-foreground">{creator.name}</span>
|
|
178
|
+
</MetaItem>
|
|
164
179
|
)}
|
|
165
180
|
{nodeName && (
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
<span className="
|
|
169
|
-
|
|
170
|
-
{nodeName}
|
|
171
|
-
</span>
|
|
172
|
-
</>
|
|
181
|
+
<MetaItem first={!creator}>
|
|
182
|
+
<ServerIcon className="h-3 w-3 shrink-0" />
|
|
183
|
+
<span className="truncate">{nodeName}</span>
|
|
184
|
+
</MetaItem>
|
|
173
185
|
)}
|
|
174
186
|
{worker.createdAt && (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
title={new Date(worker.createdAt).toLocaleString()}
|
|
180
|
-
>
|
|
181
|
-
<ClockIcon className="h-3 w-3" />
|
|
182
|
-
{formatRelativeTime(worker.createdAt)}
|
|
183
|
-
</span>
|
|
184
|
-
</>
|
|
187
|
+
<MetaItem first={!creator && !nodeName} title={new Date(worker.createdAt).toLocaleString()}>
|
|
188
|
+
<ClockIcon className="h-3 w-3 shrink-0" />
|
|
189
|
+
{formatRelativeTime(worker.createdAt)}
|
|
190
|
+
</MetaItem>
|
|
185
191
|
)}
|
|
186
192
|
{/* Only worth showing once it's over — during setup the live bar says it better. */}
|
|
187
193
|
{!status.settingUp && startupMs != null && (
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
title="Worker startup time (clone, features, postCreate…)"
|
|
193
|
-
>
|
|
194
|
-
<TimerIcon className="h-3 w-3" />
|
|
195
|
-
started in {formatDuration(startupMs)}
|
|
196
|
-
</span>
|
|
197
|
-
</>
|
|
194
|
+
<MetaItem title="Worker startup time (clone, features, postCreate…)">
|
|
195
|
+
<TimerIcon className="h-3 w-3 shrink-0" />
|
|
196
|
+
started in {formatDuration(startupMs)}
|
|
197
|
+
</MetaItem>
|
|
198
198
|
)}
|
|
199
199
|
</div>
|
|
200
200
|
</div>
|
|
@@ -223,14 +223,19 @@ export function WorkerCard({
|
|
|
223
223
|
|
|
224
224
|
{nodeOnline === false && (
|
|
225
225
|
<Banner tone="offline">
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
{/* One span, not two children: the banner spreads its children apart,
|
|
227
|
+
and an icon pinned left with its own label pinned right reads as
|
|
228
|
+
two unrelated things. */}
|
|
229
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
230
|
+
<WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
|
|
231
|
+
Node offline
|
|
232
|
+
</span>
|
|
228
233
|
</Banner>
|
|
229
234
|
)}
|
|
230
235
|
|
|
231
236
|
{outdated && (
|
|
232
237
|
<Banner tone="warn">
|
|
233
|
-
<span className="flex min-w-0 items-center gap-1.5">
|
|
238
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5">
|
|
234
239
|
<AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
|
|
235
240
|
Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
|
|
236
241
|
<span className="font-mono font-semibold">v{currentProjectVersion}</span>
|
|
@@ -250,7 +255,9 @@ export function WorkerCard({
|
|
|
250
255
|
|
|
251
256
|
{error && (
|
|
252
257
|
<Banner tone="error">
|
|
253
|
-
|
|
258
|
+
{/* Two lines then ellipsis: on a phone a single truncated line of a
|
|
259
|
+
docker error is almost always cut before the useful part. */}
|
|
260
|
+
<span className="line-clamp-2 min-w-0 flex-1 font-mono break-words">{error}</span>
|
|
254
261
|
{onDismissError && (
|
|
255
262
|
<button
|
|
256
263
|
type="button"
|
|
@@ -278,9 +285,35 @@ export function WorkerCard({
|
|
|
278
285
|
)
|
|
279
286
|
}
|
|
280
287
|
|
|
281
|
-
/**
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
/**
|
|
289
|
+
* One item of the meta line: creator, node, age, startup time.
|
|
290
|
+
*
|
|
291
|
+
* The separator is a `::before` on the item rather than a sibling element, so
|
|
292
|
+
* it can never be left behind at a line break. Below ~22 rem it disappears
|
|
293
|
+
* entirely: under ~26 rem the row is two lines deep anyway and the icons already tell
|
|
294
|
+
* the items apart, so the dots are pure noise there.
|
|
295
|
+
*/
|
|
296
|
+
function MetaItem({
|
|
297
|
+
first = false,
|
|
298
|
+
title,
|
|
299
|
+
children,
|
|
300
|
+
}: {
|
|
301
|
+
first?: boolean
|
|
302
|
+
title?: string
|
|
303
|
+
children: ReactNode
|
|
304
|
+
}) {
|
|
305
|
+
return (
|
|
306
|
+
<span
|
|
307
|
+
title={title}
|
|
308
|
+
className={cn(
|
|
309
|
+
"flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground/60",
|
|
310
|
+
!first &&
|
|
311
|
+
"before:hidden before:text-muted-foreground/30 before:content-['·'] @min-[26rem]:before:mr-1 @min-[26rem]:before:inline"
|
|
312
|
+
)}
|
|
313
|
+
>
|
|
314
|
+
{children}
|
|
315
|
+
</span>
|
|
316
|
+
)
|
|
284
317
|
}
|
|
285
318
|
|
|
286
319
|
/**
|
|
@@ -299,6 +332,9 @@ function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children:
|
|
|
299
332
|
return (
|
|
300
333
|
<div
|
|
301
334
|
className={cn(
|
|
335
|
+
// No `flex-wrap`: the action (Rebuild, ✕) stays on the message's line
|
|
336
|
+
// whatever happens — wrapping it underneath reads as a second, unrelated
|
|
337
|
+
// control. The message shrinks and wraps internally instead.
|
|
302
338
|
"mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
|
|
303
339
|
BANNER_TONES[tone]
|
|
304
340
|
)}
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,18 @@ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
|
|
|
28
28
|
// xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
|
|
29
29
|
// the write/writeln ref handle).
|
|
30
30
|
export { Terminal } from "./components/terminal"
|
|
31
|
-
export type {
|
|
31
|
+
export type {
|
|
32
|
+
TerminalHandle,
|
|
33
|
+
TerminalProps,
|
|
34
|
+
TerminalOptions,
|
|
35
|
+
TerminalSearchOptions,
|
|
36
|
+
TerminalViewportState,
|
|
37
|
+
} from "./components/terminal"
|
|
38
|
+
|
|
39
|
+
// The frame around a Terminal — bar (title/status/actions) + surface + the
|
|
40
|
+
// connection/empty/loading/error states. Its ref *is* a TerminalHandle.
|
|
41
|
+
export { TerminalPanel } from "./components/terminal-panel"
|
|
42
|
+
export type { TerminalPanelProps, TerminalPanelStatus } from "./components/terminal-panel"
|
|
32
43
|
|
|
33
44
|
// Docker image pull as a real UI (one segmented bar, one block per layer) —
|
|
34
45
|
// presentational only: give it a snapshot, it draws it.
|