| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { useSyncExternalStore } from "@alchemy.run/sigil/react"; |
| 4 | import { useGlyphs } from "./Environment.tsx"; |
| 5 | import { SpinnerGlyph } from "./Feedback.tsx"; |
| 6 | import { ProgressBar } from "./ProgressBar.tsx"; |
| 7 | import { Box, Row, Stack } from "./Layout.tsx"; |
| 8 | import { Text } from "./Typography.tsx"; |
| 9 | import { theme } from "../../../Util/Theme.ts"; |
| 10 | |
| 11 | export 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 | */ |
| 31 | export 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 | |
| 55 | export 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 | |
| 64 | type 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 | |
| 71 | export 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 | |
| 112 | export 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 | |
| 130 | export const useLiveStore = <Value,>(store: LiveStore<Value>): Value => |
| 131 | useSyncExternalStore(store.subscribe, store.snapshot); |