@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
|
@@ -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 }
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
// @spunto/design-system — shared UI primitives, helpers, and color constants.
|
|
2
2
|
// Tokens ship as a CSS file: `import "@spunto/design-system/styles.css"`.
|
|
3
3
|
|
|
4
|
-
export { cn
|
|
4
|
+
export { cn } from "./utils"
|
|
5
5
|
export * from "./colors"
|
|
6
6
|
|
|
7
|
-
// Domain (Spunto-specific) components live behind their own entry point —
|
|
8
|
-
// `@spunto/design-system/workers` — so this root stays domain-free primitives.
|
|
9
|
-
// See README § "Domain components".
|
|
10
|
-
|
|
11
7
|
export { Button } from "./components/button"
|
|
12
8
|
// `buttonVariants` is exported from its own directive-free module (not through the
|
|
13
9
|
// "use client" button.tsx) so it stays callable from React Server Components.
|
|
@@ -28,7 +24,18 @@ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
|
|
|
28
24
|
// xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
|
|
29
25
|
// the write/writeln ref handle).
|
|
30
26
|
export { Terminal } from "./components/terminal"
|
|
31
|
-
export type {
|
|
27
|
+
export type {
|
|
28
|
+
TerminalHandle,
|
|
29
|
+
TerminalProps,
|
|
30
|
+
TerminalOptions,
|
|
31
|
+
TerminalSearchOptions,
|
|
32
|
+
TerminalViewportState,
|
|
33
|
+
} from "./components/terminal"
|
|
34
|
+
|
|
35
|
+
// The frame around a Terminal — bar (title/status/actions) + surface + the
|
|
36
|
+
// connection/empty/loading/error states. Its ref *is* a TerminalHandle.
|
|
37
|
+
export { TerminalPanel } from "./components/terminal-panel"
|
|
38
|
+
export type { TerminalPanelProps, TerminalPanelStatus } from "./components/terminal-panel"
|
|
32
39
|
|
|
33
40
|
// Docker image pull as a real UI (one segmented bar, one block per layer) —
|
|
34
41
|
// presentational only: give it a snapshot, it draws it.
|
package/src/utils.ts
CHANGED
|
@@ -5,26 +5,3 @@ import { twMerge } from "tailwind-merge"
|
|
|
5
5
|
export function cn(...inputs: ClassValue[]) {
|
|
6
6
|
return twMerge(clsx(inputs))
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Coarse "3h ago" stamp — minutes, then hours, then days. Deliberately not a
|
|
11
|
-
* live-ticking relative time: every surface using it (worker cards, tables)
|
|
12
|
-
* already re-renders on its own polling cadence, and a second timer per row
|
|
13
|
-
* would cost more than the precision is worth.
|
|
14
|
-
*/
|
|
15
|
-
export function formatRelativeTime(date: string | number | Date): string {
|
|
16
|
-
const ms = date instanceof Date ? date.getTime() : new Date(date).getTime()
|
|
17
|
-
if (!Number.isFinite(ms)) return ""
|
|
18
|
-
const minutes = Math.floor((Date.now() - ms) / 60_000)
|
|
19
|
-
if (minutes < 1) return "just now"
|
|
20
|
-
if (minutes < 60) return `${minutes}m ago`
|
|
21
|
-
const hours = Math.floor(minutes / 60)
|
|
22
|
-
if (hours < 24) return `${hours}h ago`
|
|
23
|
-
return `${Math.floor(hours / 24)}d ago`
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Short duration, one significant decimal under 10 s ("840ms", "4.2s", "38s"). */
|
|
27
|
-
export function formatDuration(ms: number): string {
|
|
28
|
-
if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
|
|
29
|
-
return `${Math.max(0, Math.round(ms))}ms`
|
|
30
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// Who spawned the worker, in 20 px.
|
|
2
|
-
//
|
|
3
|
-
// A plain <img>, deliberately: the design system is consumed by two Next apps
|
|
4
|
-
// *and* by a Vite showcase, so it can't reach for `next/image`. Avatars come
|
|
5
|
-
// from GitHub/Google CDNs already sized for this.
|
|
6
|
-
|
|
7
|
-
import { cn } from "../../utils"
|
|
8
|
-
import type { WorkerCreator } from "./types"
|
|
9
|
-
|
|
10
|
-
export function CreatorAvatar({ creator, className }: { creator: WorkerCreator; className?: string }) {
|
|
11
|
-
if (creator.avatarUrl) {
|
|
12
|
-
return (
|
|
13
|
-
<img
|
|
14
|
-
src={creator.avatarUrl}
|
|
15
|
-
alt={creator.name}
|
|
16
|
-
className={cn("h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-border", className)}
|
|
17
|
-
/>
|
|
18
|
-
)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const initials = creator.name
|
|
22
|
-
.split(" ")
|
|
23
|
-
.map((n) => n[0])
|
|
24
|
-
.join("")
|
|
25
|
-
.toUpperCase()
|
|
26
|
-
.slice(0, 2)
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<span
|
|
30
|
-
className={cn(
|
|
31
|
-
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-muted-foreground ring-1 ring-border",
|
|
32
|
-
className
|
|
33
|
-
)}
|
|
34
|
-
>
|
|
35
|
-
{initials}
|
|
36
|
-
</span>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// "repo · branch +3" chips — what a worker is actually working on.
|
|
2
|
-
//
|
|
3
|
-
// The dashboard's version shows the modified count; Lite's also shows ahead /
|
|
4
|
-
// behind. This is the union: every counter is rendered only when the caller
|
|
5
|
-
// supplies it, so neither app has to send a field it doesn't have.
|
|
6
|
-
|
|
7
|
-
import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon, GitBranchIcon } from "lucide-react"
|
|
8
|
-
|
|
9
|
-
import { cn } from "../../utils"
|
|
10
|
-
|
|
11
|
-
export interface GitRepoStatus {
|
|
12
|
-
/** Absolute path of the checkout inside the worker — also the React key. */
|
|
13
|
-
path: string
|
|
14
|
-
branch?: string | null
|
|
15
|
-
/** Display name; defaults to the last segment of `path`. */
|
|
16
|
-
name?: string
|
|
17
|
-
/** Uncommitted files. */
|
|
18
|
-
modified?: number
|
|
19
|
-
/** Commits ahead of / behind the upstream branch. */
|
|
20
|
-
ahead?: number
|
|
21
|
-
behind?: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface GitBranchChipsProps {
|
|
25
|
-
repos: GitRepoStatus[]
|
|
26
|
-
/**
|
|
27
|
-
* Chip → URL. A slot, not a link component: the design system knows nothing
|
|
28
|
-
* about code-server URLs, and returning `undefined` renders a plain span.
|
|
29
|
-
*/
|
|
30
|
-
repoHref?: (repo: GitRepoStatus) => string | undefined
|
|
31
|
-
className?: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function repoName(repo: GitRepoStatus): string {
|
|
35
|
-
return repo.name ?? repo.path.split("/").filter(Boolean).pop() ?? repo.path
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** One chip per checked-out repository, wrapping onto as many lines as needed. */
|
|
39
|
-
export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsProps) {
|
|
40
|
-
if (!repos.length) return null
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<div data-slot="git-branch-chips" className={cn("flex flex-wrap gap-1.5", className)}>
|
|
44
|
-
{repos.map((repo) => {
|
|
45
|
-
const href = repoHref?.(repo)
|
|
46
|
-
const Tag = href ? "a" : "span"
|
|
47
|
-
return (
|
|
48
|
-
<Tag
|
|
49
|
-
key={repo.path}
|
|
50
|
-
{...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
51
|
-
title={repo.path}
|
|
52
|
-
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",
|
|
54
|
-
href && "transition-colors hover:border-border/80 hover:bg-muted/70"
|
|
55
|
-
)}
|
|
56
|
-
>
|
|
57
|
-
<span className="font-medium text-foreground/70">{repoName(repo)}</span>
|
|
58
|
-
<span className="mx-0.5 text-muted-foreground/40">·</span>
|
|
59
|
-
<GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
|
|
60
|
-
<span className="max-w-[120px] truncate">{repo.branch || "—"}</span>
|
|
61
|
-
<RepoCounters repo={repo} />
|
|
62
|
-
{href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 opacity-40" />}
|
|
63
|
-
</Tag>
|
|
64
|
-
)
|
|
65
|
-
})}
|
|
66
|
-
</div>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Amber for local work not yet committed/pushed, muted for what's merely behind. */
|
|
71
|
-
function RepoCounters({ repo }: { repo: GitRepoStatus }) {
|
|
72
|
-
return (
|
|
73
|
-
<>
|
|
74
|
-
{!!repo.modified && <span className="ml-0.5 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
|
|
75
|
-
{!!repo.ahead && (
|
|
76
|
-
<span className="ml-0.5 inline-flex items-center text-amber-500 dark:text-amber-400">
|
|
77
|
-
<ArrowUpIcon className="h-2.5 w-2.5" />
|
|
78
|
-
{repo.ahead}
|
|
79
|
-
</span>
|
|
80
|
-
)}
|
|
81
|
-
{!!repo.behind && (
|
|
82
|
-
<span className="ml-0.5 inline-flex items-center text-muted-foreground/70">
|
|
83
|
-
<ArrowDownIcon className="h-2.5 w-2.5" />
|
|
84
|
-
{repo.behind}
|
|
85
|
-
</span>
|
|
86
|
-
)}
|
|
87
|
-
</>
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface GitStatusSummaryProps {
|
|
92
|
-
repos: GitRepoStatus[]
|
|
93
|
-
/** Heading above the list — e.g. "Open in VS Code" inside a tooltip. */
|
|
94
|
-
title?: string
|
|
95
|
-
className?: string
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* The same information as the chips, stacked one repo per line. Meant for the
|
|
100
|
-
* inside of a tooltip or a popover, where chips would wrap badly — the tooltip
|
|
101
|
-
* itself stays the app's business (the design system doesn't decide what opens
|
|
102
|
-
* it).
|
|
103
|
-
*/
|
|
104
|
-
export function GitStatusSummary({ repos, title, className }: GitStatusSummaryProps) {
|
|
105
|
-
if (!repos.length) return null
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<span data-slot="git-status-summary" className={cn("flex flex-col gap-1", className)}>
|
|
109
|
-
{title && <span className="text-[10px] uppercase tracking-wide opacity-60">{title}</span>}
|
|
110
|
-
{repos.map((repo) => (
|
|
111
|
-
<span key={repo.path} className="flex items-center gap-1.5">
|
|
112
|
-
<span className="font-medium">{repoName(repo)}</span>
|
|
113
|
-
<GitBranchIcon className="h-3 w-3 opacity-60" />
|
|
114
|
-
<span className="font-mono">{repo.branch || "—"}</span>
|
|
115
|
-
<RepoCounters repo={repo} />
|
|
116
|
-
</span>
|
|
117
|
-
))}
|
|
118
|
-
</span>
|
|
119
|
-
)
|
|
120
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// @spunto/design-system/workers — Spunto's *domain* components.
|
|
2
|
-
//
|
|
3
|
-
// Deliberately a sub-export rather than part of the main entry: the root of the
|
|
4
|
-
// package stays "primitives with no domain" (a Button knows nothing about a
|
|
5
|
-
// worker), and a third party consuming the design system never has to carry the
|
|
6
|
-
// notion of a worker. New business components go here — see the README's
|
|
7
|
-
// "Domain components" section for the rule.
|
|
8
|
-
|
|
9
|
-
export { WorkerCard } from "./worker-card"
|
|
10
|
-
export type { WorkerCardProps, LinkRender } from "./worker-card"
|
|
11
|
-
|
|
12
|
-
export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
|
|
13
|
-
export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
|
|
14
|
-
|
|
15
|
-
export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
|
|
16
|
-
export type { SetupProgressProps, StepState } from "./setup-progress"
|
|
17
|
-
|
|
18
|
-
export { ResourceBars } from "./resource-bars"
|
|
19
|
-
export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
|
|
20
|
-
|
|
21
|
-
export { GitBranchChips, GitStatusSummary, repoName } from "./git-branch-chips"
|
|
22
|
-
export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps } from "./git-branch-chips"
|
|
23
|
-
|
|
24
|
-
export { TagChips } from "./tag-chips"
|
|
25
|
-
export type { TagChipsProps } from "./tag-chips"
|
|
26
|
-
export { tagColor } from "./tag-color"
|
|
27
|
-
export type { TagPalette } from "./tag-color"
|
|
28
|
-
|
|
29
|
-
export { CreatorAvatar } from "./creator-avatar"
|
|
30
|
-
|
|
31
|
-
export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
|
|
32
|
-
|
|
33
|
-
// Re-exported here too so a consumer of the worker surface doesn't have to
|
|
34
|
-
// import the root entry just to format the timestamps it feeds these components.
|
|
35
|
-
export { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// CPU + memory of a running container, as two thin bars.
|
|
2
|
-
//
|
|
3
|
-
// Written identically in the dashboard's card and its table row — the `compact`
|
|
4
|
-
// flag is the only thing that ever differed between the two copies.
|
|
5
|
-
|
|
6
|
-
import type { ReactNode } from "react"
|
|
7
|
-
import { CpuIcon, MemoryStickIcon } from "lucide-react"
|
|
8
|
-
|
|
9
|
-
import { cn } from "../../utils"
|
|
10
|
-
|
|
11
|
-
export interface WorkerStats {
|
|
12
|
-
cpuPercent: number
|
|
13
|
-
memUsageMb: number
|
|
14
|
-
memLimitMb?: number
|
|
15
|
-
memPercent?: number
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ResourceBarsProps {
|
|
19
|
-
stats: WorkerStats
|
|
20
|
-
/** Drops the framed panel — just the two bars, for a table cell or a tooltip. */
|
|
21
|
-
compact?: boolean
|
|
22
|
-
className?: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Past this, the bar turns red: the container is about to be the problem. */
|
|
26
|
-
const DANGER = 80
|
|
27
|
-
|
|
28
|
-
export function ResourceBars({ stats, compact = false, className }: ResourceBarsProps) {
|
|
29
|
-
const cpu = stats.cpuPercent ?? 0
|
|
30
|
-
const mem = stats.memPercent ?? 0
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<div
|
|
34
|
-
data-slot="resource-bars"
|
|
35
|
-
className={cn(
|
|
36
|
-
"grid grid-cols-2 gap-3",
|
|
37
|
-
!compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
|
|
38
|
-
className
|
|
39
|
-
)}
|
|
40
|
-
>
|
|
41
|
-
<Metric
|
|
42
|
-
icon={<CpuIcon className="h-3 w-3" />}
|
|
43
|
-
label="CPU"
|
|
44
|
-
value={`${cpu.toFixed(1)}%`}
|
|
45
|
-
valueClass={cpu > DANGER ? "text-orange-500 dark:text-orange-400" : "text-foreground/70"}
|
|
46
|
-
percent={cpu}
|
|
47
|
-
// Flame for CPU, Build amber for memory: two bars side by side need to be
|
|
48
|
-
// told apart at a glance, and both hues already mean "compute" here.
|
|
49
|
-
barClass={cpu > DANGER ? "bg-destructive" : "bg-primary"}
|
|
50
|
-
/>
|
|
51
|
-
<Metric
|
|
52
|
-
icon={<MemoryStickIcon className="h-3 w-3" />}
|
|
53
|
-
label="Memory"
|
|
54
|
-
value={
|
|
55
|
-
<>
|
|
56
|
-
{(stats.memUsageMb ?? 0).toFixed(0)}
|
|
57
|
-
<span className="text-muted-foreground/50"> MB</span>
|
|
58
|
-
</>
|
|
59
|
-
}
|
|
60
|
-
valueClass="text-foreground/70"
|
|
61
|
-
percent={mem}
|
|
62
|
-
barClass={mem > DANGER ? "bg-destructive" : "bg-build"}
|
|
63
|
-
/>
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function Metric({
|
|
69
|
-
icon,
|
|
70
|
-
label,
|
|
71
|
-
value,
|
|
72
|
-
valueClass,
|
|
73
|
-
percent,
|
|
74
|
-
barClass,
|
|
75
|
-
}: {
|
|
76
|
-
icon: ReactNode
|
|
77
|
-
label: string
|
|
78
|
-
value: ReactNode
|
|
79
|
-
valueClass: string
|
|
80
|
-
percent: number
|
|
81
|
-
barClass: string
|
|
82
|
-
}) {
|
|
83
|
-
return (
|
|
84
|
-
<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">
|
|
87
|
-
{icon}
|
|
88
|
-
{label}
|
|
89
|
-
</span>
|
|
90
|
-
<span className={cn("font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
|
|
91
|
-
</div>
|
|
92
|
-
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
93
|
-
<div
|
|
94
|
-
className={cn("h-full rounded-full transition-all duration-500 motion-reduce:transition-none", barClass)}
|
|
95
|
-
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
|
|
96
|
-
/>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
)
|
|
100
|
-
}
|