@spunto/design-system 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, formatDuration, formatRelativeTime } from "./utils"
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 { TerminalHandle, TerminalProps, TerminalOptions } from "./components/terminal"
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
- }