1/** @jsxImportSource @alchemy.run/sigil */
2import type { ReactNode } from "react";
3import { useSyncExternalStore } from "@alchemy.run/sigil/react";
4import { useGlyphs } from "./Environment.tsx";
5import { SpinnerGlyph } from "./Feedback.tsx";
6import { ProgressBar } from "./ProgressBar.tsx";
7import { Box, Row, Stack } from "./Layout.tsx";
8import { Text } from "./Typography.tsx";
9import { theme } from "../../../Util/Theme.ts";
10
11export interface TaskRowProps {
12 /** Status glyph. Ignored while `spinning`; defaults to the bullet glyph. */
13 readonly icon?: string;
14 /** Color for the glyph (and the spinner while `spinning`). */
15 readonly iconColor?: string;
16 /** Render an animated spinner frame in the glyph slot. */
17 readonly spinning?: boolean;
18 readonly label: ReactNode;
19 /** Muted annotation directly after the label. */
20 readonly detail?: ReactNode;
21 /** Indentation in 2-space units. */
22 readonly depth?: number;
23 /** Extra trailing cells (status labels, chips). */
24 readonly children?: ReactNode;
25}
26
27/**
28 * The one status row shape shared by TaskTree and the plan/apply views:
29 * glyph-or-spinner, bold label, muted detail, then any trailing cells.
30 */
31export function TaskRow({
32 icon,
33 iconColor,
34 spinning = false,
35 label,
36 detail,
37 depth = 0,
38 children,
39}: TaskRowProps) {
40 const glyphs = useGlyphs();
41 return (
42 <Row gap={1} paddingLeft={depth * theme.space.indent}>
43 {spinning ? (
44 <SpinnerGlyph color={iconColor} />
45 ) : (
46 <Text color={iconColor}>{icon ?? glyphs.bullet}</Text>
47 )}
48 <Text bold={depth === 0}>{label}</Text>
49 {detail === undefined ? null : <Text tone="muted">· {detail}</Text>}
50 {children}
51 </Row>
52 );
53}
54
55export interface ProgressGroupRow {
56 readonly id: string;
57 readonly label: ReactNode;
58 readonly completed: number;
59 readonly total: number;
60 readonly failed?: number;
61 readonly detail?: ReactNode;
62}
63
64type ProgressGroupProps = {
65 readonly rows: ReadonlyArray<ProgressGroupRow>;
66 readonly width?: number;
67 /** Fixed label column (truncated) so the count cells align across rows. */
68 readonly labelWidth?: number;
69};
70
71export function ProgressGroup({
72 rows,
73 width = 20,
74 labelWidth,
75}: ProgressGroupProps) {
76 return (
77 <Stack>
78 {rows.map((row) => {
79 const complete = row.total > 0 && row.completed >= row.total;
80 const variant = row.failed ? "error" : complete ? "success" : "info";
81 return (
82 <Row key={row.id}>
83 <ProgressBar
84 value={row.total <= 0 ? 0 : row.completed / row.total}
85 width={width}
86 showPercent={false}
87 variant={variant}
88 />
89 {labelWidth === undefined ? (
90 <Text>{row.label}</Text>
91 ) : (
92 <Box width={labelWidth} flexShrink={0}>
93 <Text wrap="truncate-end">{row.label}</Text>
94 </Box>
95 )}
96 <Text bold tone={variant === "error" ? "danger" : variant}>
97 {row.completed}/{row.total}
98 </Text>
99 {row.failed ? (
100 <Text tone="danger">({row.failed} failed)</Text>
101 ) : null}
102 {row.detail === undefined ? null : (
103 <Text tone="muted">{row.detail}</Text>
104 )}
105 </Row>
106 );
107 })}
108 </Stack>
109 );
110}
111
112export class LiveStore<Value> {
113 private value: Value;
114 private readonly listeners = new Set<() => void>();
115 constructor(initial: Value) {
116 this.value = initial;
117 }
118 readonly subscribe = (listener: () => void) => {
119 this.listeners.add(listener);
120 return () => this.listeners.delete(listener);
121 };
122 readonly snapshot = () => this.value;
123 readonly set = (value: Value) => {
124 this.value = value;
125 for (const listener of this.listeners) listener();
126 };
127 readonly update = (f: (value: Value) => Value) => this.set(f(this.value));
128}
129
130export const useLiveStore = <Value,>(store: LiveStore<Value>): Value =>
131 useSyncExternalStore(store.subscribe, store.snapshot);