1/** @jsxImportSource @alchemy.run/sigil */
2/**
3 * THE plan renderer. Every surface that shows a plan tree — `alchemy plan`
4 * output, the approval prompt, and the live apply/dev/destroy progress —
5 * renders through this one state-driven component, so a plan always looks
6 * the same wherever it appears.
7 *
8 * - One {@link PlanTree} holds the flattened tree (namespaces,
9 * resources, bindings, actions) plus per-row runtime state (apply status
10 * and the row's latest log message), updated from engine
11 * {@link ApplyEvent}s.
12 * - Two presentation `mode`s: `review` renders action glyphs (`+ ~ - ±`),
13 * `apply` renders live statuses with spinners and per-row messages.
14 * - `virtual` viewports follow the active row and support keyboard scrolling;
15 * `full` viewports produce static terminal scrollback. This choice is
16 * independent of whether the tree will receive more events.
17 * - Property diffs (`detailed`) render in every mode, and the window is
18 * line-budget aware so multi-line rows never overflow the terminal.
19 */
20import { useMemo, useSyncExternalStore } from "@alchemy.run/sigil/react";
21import type { JSX, ReactNode } from "react";
22import {
23 Box,
24 KeyBar,
25 Row,
26 SectionHeading,
27 SpinnerGlyph,
28 TaskRow,
29 Text,
30 useBorderStyle,
31 useGlyphs,
32 useKeyGlyphs,
33} from "../ui/index.ts";
34import type { Plan as AlchemyPlan } from "../../../Plan.ts";
35import type { ApplyStatus } from "../../../Report.ts";
36import type { ActionVerb, PlanSummaryCounts } from "../../NamespaceTree.ts";
37import { formatModeNote } from "../../ModeTag.ts";
38import { theme } from "../../CliKit/index.ts";
39import { formatElapsed } from "../../Format.ts";
40import {
41 actionStyle,
42 applyStatusColor,
43 isInProgress,
44 isTerminalStatus,
45} from "./statusStyle.ts";
46import { matchYamlChange, matchYamlKey } from "../../PropertyDiff.ts";
47import { NamespaceRow, namespaceStyle } from "./PlanRow.tsx";
48import { StackOutputs } from "./StackOutputs.tsx";
49import {
50 PlanTree,
51 initialResourceState,
52 type PlanRow,
53 type PlanTreeState,
54 type ResourceRow,
55 type RowState,
56} from "./PlanTree.ts";
57import { usePlanViewport } from "./usePlanViewport.ts";
58import { usePlanPresentation } from "./usePlanPresentation.ts";
59
60export { PlanTree } from "./PlanTree.ts";
61export type {
62 PlanOutcome,
63 PlanProgress,
64 PlanRow,
65 PlanTreeOptions,
66 PlanTreeState,
67 PlanView,
68 PlanViewport,
69 RowState,
70} from "./PlanTree.ts";
71export type { PlanSummaryCounts } from "../../NamespaceTree.ts";
72
73/**
74 * Row detail: the latest log/annotation line, painted danger when the row
75 * failed so the error reads at the row instead of only in the final dump.
76 */
77const rowDetail = (status: ApplyStatus, message: string | undefined) =>
78 message !== undefined && status === "fail" ? (
79 <Text color={theme.color.danger}>{message}</Text>
80 ) : (
81 message
82 );
83
84// ── Component ─────────────────────────────────────────────────────────────
85
86export interface PlanProps {
87 /** Reactive tree containing rows, statuses, label, and presentation state. */
88 tree: PlanTree;
89 /** Allow the tree to collapse behind `p`. */
90 collapsible?: boolean;
91 /** Widget rendered before the summary block. */
92 before?: ReactNode;
93 /** Widget rendered on the summary row before the summary. */
94 summaryBefore?: ReactNode;
95 /** Widget rendered on the summary row after the summary. */
96 summaryAfter?: ReactNode;
97 /** Widget rendered below the virtualized rows. */
98 footer?: ReactNode;
99 /** Widget rendered after the complete Plan. */
100 after?: ReactNode;
101}
102
103const usePlanTree = (tree: PlanTree): PlanTreeState => {
104 const subscribe = useMemo(() => tree.subscribe.bind(tree), [tree]);
105 const snapshot = useMemo(() => tree.snapshot.bind(tree), [tree]);
106 return useSyncExternalStore(subscribe, snapshot);
107};
108
109export function Plan(props: PlanProps): JSX.Element {
110 const {
111 tree,
112 collapsible = false,
113 before,
114 summaryBefore,
115 summaryAfter,
116 footer,
117 after,
118 } = props;
119 const { mode } = tree;
120 const keyGlyphs = useKeyGlyphs();
121 const borderStyle = useBorderStyle();
122 const state = usePlanTree(tree);
123 const { tasks, label, viewport, busy } = state;
124 const {
125 progress: { completed, total: workRows },
126 collapsed,
127 showControls,
128 lineBudget,
129 refs: { beforeRef, summaryRef, controlsRef, afterRef },
130 } = usePlanPresentation({
131 tree,
132 state,
133 busy,
134 collapsible,
135 hasFooter: footer !== undefined,
136 });
137 const {
138 selectedView,
139 hasOutput,
140 virtual: virtualizing,
141 budget: budgetRows,
142 offset: shownOffset,
143 hiddenBelow,
144 planLines: visibleLines,
145 } = usePlanViewport({
146 tree,
147 state,
148 lineBudget,
149 collapsible,
150 collapsed,
151 });
152
153 // ── Summary and operation bar ─────────────────────────────────────────
154 const operationProgress =
155 mode === "apply" ? (
156 <>
157 {busy ? (
158 <>
159 <SpinnerGlyph color={theme.color.brand} />
160 <Text> </Text>
161 </>
162 ) : null}
163 <SectionHeading>{label}</SectionHeading>
164 <Text tone="muted">
165 {workRows === 0 ? "" : ` (${completed}/${workRows})`}
166 </Text>
167 </>
168 ) : null;
169 const planSummary =
170 selectedView === "output" ? (
171 <SectionHeading>Output</SectionHeading>
172 ) : mode === "apply" ? (
173 <ApplySummary label="Plan" rows={tree.progressRows} tasks={tasks} />
174 ) : (
175 <ReviewSummary label={label} summary={tree.summary} />
176 );
177 const planKeys: ReadonlyArray<readonly [string, string]> = [
178 ...(!collapsed && viewport === "virtual"
179 ? [[keyGlyphs.upDown, `scroll ${selectedView}`] as const]
180 : []),
181 ...(!collapsed && hasOutput
182 ? [
183 [
184 keyGlyphs.leftRight,
185 selectedView === "plan" ? "show output" : "show plan",
186 ] as const,
187 ]
188 : []),
189 ["p", collapsed ? "show plan/output" : "hide widget"],
190 ["Ctrl+C", "exit"],
191 ];
192
193 return (
194 <Box flexDirection="column">
195 <Box ref={beforeRef} flexDirection="column">
196 {before}
197 </Box>
198 {collapsed ? null : (
199 <Box
200 ref={summaryRef}
201 marginBottom={1}
202 borderStyle={borderStyle}
203 borderTop
204 borderBottom={false}
205 borderLeft={false}
206 borderRight={false}
207 borderColor={theme.color.muted}
208 borderDimColor
209 >
210 {summaryBefore}
211 {planSummary}
212 {summaryAfter}
213 </Box>
214 )}
215 {collapsed ? null : (
216 <PlanContent
217 tree={tree}
218 state={state}
219 selectedView={selectedView}
220 virtual={virtualizing}
221 budget={budgetRows}
222 offset={shownOffset}
223 hiddenBelow={hiddenBelow}
224 lines={visibleLines}
225 />
226 )}
227 {showControls ? (
228 <Box
229 ref={controlsRef}
230 marginTop={collapsed ? 0 : 1}
231 borderStyle={borderStyle}
232 borderTop
233 borderBottom={false}
234 borderLeft={false}
235 borderRight={false}
236 borderColor={theme.color.muted}
237 borderDimColor
238 >
239 {collapsible ? (
240 <KeyBar
241 inline
242 marginTop={0}
243 before={operationProgress}
244 keys={planKeys}
245 after={footer}
246 divider={mode === "apply" || footer !== undefined}
247 />
248 ) : mode === "apply" ? (
249 <>
250 {operationProgress}
251 {footer}
252 </>
253 ) : (
254 footer
255 )}
256 </Box>
257 ) : null}
258 <Box ref={afterRef} flexDirection="column">
259 {after}
260 </Box>
261 </Box>
262 );
263}
264
265function PlanContent(props: {
266 readonly tree: PlanTree;
267 readonly state: PlanTreeState;
268 readonly selectedView: "plan" | "output";
269 readonly virtual: boolean;
270 readonly budget: number;
271 readonly offset: number;
272 readonly hiddenBelow: number;
273 readonly lines: ReturnType<typeof usePlanViewport>["planLines"];
274}) {
275 const { tree, state, selectedView, virtual, budget, offset, hiddenBelow } =
276 props;
277 const glyphs = useGlyphs();
278 const overflowUnit = virtual ? "lines" : "rows";
279 return (
280 <Box flexDirection="column">
281 {offset > 0 ? (
282 <Text tone="muted">
283 {glyphs.overflowUp} {offset} earlier {overflowUnit}
284 </Text>
285 ) : null}
286 {selectedView === "output" ? (
287 <StackOutputs
288 value={state.output}
289 offset={offset}
290 limit={virtual ? budget : undefined}
291 />
292 ) : props.lines === undefined ? (
293 tree.rows.map((row) => (
294 <PlanRowView
295 key={row.key}
296 row={row}
297 mode={tree.mode}
298 detailed={tree.detailed}
299 state={state.tasks.get(
300 row.type === "binding" ? row.hostKey : row.key,
301 )}
302 defaultMode={tree.defaultMode}
303 />
304 ))
305 ) : (
306 props.lines.map((line) =>
307 line.kind === "row" ? (
308 <PlanRowView
309 key={line.row.key}
310 row={line.row}
311 mode={tree.mode}
312 detailed={tree.detailed}
313 includeYaml={false}
314 state={state.tasks.get(
315 line.row.type === "binding" ? line.row.hostKey : line.row.key,
316 )}
317 defaultMode={tree.defaultMode}
318 />
319 ) : line.kind === "yaml" ? (
320 <YamlLine
321 key={line.key}
322 line={line.line}
323 paddingLeft={line.paddingLeft}
324 />
325 ) : (
326 <DetailLine key={line.key} paddingLeft={line.paddingLeft}>
327 <Text tone="muted" dimColor>
328 no declared property changes
329 </Text>
330 </DetailLine>
331 ),
332 )
333 )}
334 {hiddenBelow > 0 ? (
335 <Text tone="muted">
336 {glyphs.overflowDown} {hiddenBelow} more {overflowUnit}
337 </Text>
338 ) : null}
339 </Box>
340 );
341}
342
343// ── Rows ──────────────────────────────────────────────────────────────────
344
345function PlanRowView(props: {
346 row: PlanRow;
347 mode: "review" | "apply";
348 detailed: boolean;
349 state: RowState | undefined;
350 defaultMode: AlchemyPlan["defaultMode"];
351 includeYaml?: boolean;
352}): JSX.Element {
353 const { row, mode, detailed, state, defaultMode, includeYaml = true } = props;
354 const glyphs = useGlyphs();
355
356 if (row.type === "namespace") {
357 return <NamespaceRow id={row.id} depth={row.depth} action={row.action} />;
358 }
359
360 if (row.type === "binding") {
361 if (mode === "review") {
362 const style =
363 row.action === "delete"
364 ? { color: theme.color.muted, icon: "delete" as const }
365 : namespaceStyle(row.action);
366 return (
367 <Row gap={1} paddingLeft={row.depth * 2}>
368 <Text color={style.color}>{glyphs[style.icon]}</Text>
369 <Text
370 color={
371 row.action === "delete" ? theme.color.muted : theme.color.info
372 }
373 >
374 {row.id}
375 </Text>
376 {row.action === "delete" ? <Text tone="muted">(unbind)</Text> : null}
377 </Row>
378 );
379 }
380 // `state` is the HOST resource's row state: a binding is reconciled by
381 // the resource that carries it, so it settles exactly when the host does.
382 const hostStatus: ApplyStatus = state?.status ?? "pending";
383 const status: ApplyStatus | "no change" =
384 row.action === "noop" ? "no change" : hostStatus;
385 const color =
386 row.action === "delete" ? theme.color.muted : applyStatusColor(status);
387 const bindingStatus =
388 row.action !== "delete"
389 ? status
390 : hostStatus === "fail"
391 ? "fail"
392 : isTerminalStatus(hostStatus)
393 ? "unbound"
394 : isInProgress(hostStatus)
395 ? "unbinding"
396 : "unbind";
397 return (
398 <TaskRow
399 spinning={status !== "no change" && isInProgress(hostStatus)}
400 icon={
401 status === "pending"
402 ? glyphs.bullet
403 : status === "fail"
404 ? glyphs.error
405 : glyphs.success
406 }
407 iconColor={color}
408 label={
409 <Text
410 color={
411 row.action === "delete" ? theme.color.muted : theme.color.info
412 }
413 >
414 {row.id}
415 </Text>
416 }
417 depth={row.depth}
418 >
419 <Text color={color}>{bindingStatus}</Text>
420 </TaskRow>
421 );
422 }
423
424 if (row.type === "task") {
425 if (mode === "review") {
426 const style = namespaceStyle(row.action);
427 return (
428 <TaskRow
429 icon={glyphs[style.icon]}
430 iconColor={style.color}
431 label={row.id}
432 depth={row.depth}
433 >
434 <Text color={theme.color.info}>[action]</Text>
435 </TaskRow>
436 );
437 }
438 const status: ApplyStatus =
439 state?.status ?? (row.action === "noop" ? "ran" : "pending");
440 const color = applyStatusColor(status);
441 return (
442 <TaskRow
443 spinning={isInProgress(status)}
444 icon={taskIcon(row.action, status, glyphs)}
445 iconColor={color}
446 label={row.id}
447 detail={rowDetail(status, state?.message)}
448 depth={row.depth}
449 >
450 <Text color={color}>{taskLabel(row.action, status)}</Text>
451 <Text color={theme.color.info} dimColor>
452 [action]
453 </Text>
454 {state?.elapsedMs === undefined ? null : (
455 <Text tone="muted">({formatElapsed(state.elapsedMs)})</Text>
456 )}
457 </TaskRow>
458 );
459 }
460
461 // Resource row.
462 const modeNote = formatModeNote({
463 mode: row.providerMode,
464 priorMode: row.fromProviderMode,
465 defaultMode,
466 });
467 const showYaml =
468 includeYaml && (detailed || row.propertyYaml?.kind === "drift");
469 const yaml = showYaml ? (
470 row.propertyYaml === undefined ? (
471 row.action === "update" ||
472 row.action === "adopted" ||
473 row.action === "replace" ? (
474 <DetailLine paddingLeft={row.depth * 2 + 2}>
475 <Text tone="muted" dimColor>
476 no declared property changes
477 </Text>
478 </DetailLine>
479 ) : null
480 ) : (
481 row.propertyYaml.lines.map((line, index) => (
482 <YamlLine
483 key={`${index}:${line}`}
484 line={line}
485 paddingLeft={row.depth * 2 + 2}
486 />
487 ))
488 )
489 ) : null;
490
491 if (mode === "review") {
492 const style = namespaceStyle(row.action);
493 return (
494 <Box flexDirection="column" marginTop={showYaml ? 1 : 0}>
495 <TaskRow
496 icon={glyphs[style.icon]}
497 iconColor={style.color}
498 label={
499 <Text color={row.action === "noop" ? undefined : style.color}>
500 {row.id}
501 </Text>
502 }
503 depth={row.depth}
504 detail={row.detail}
505 >
506 {modeNote && <Text tone="muted">({modeNote})</Text>}
507 {row.id !== row.resourceType ? (
508 <Text tone="muted">({row.resourceType})</Text>
509 ) : null}
510 </TaskRow>
511 {yaml}
512 </Box>
513 );
514 }
515
516 const rowState = state ?? initialResourceState(row);
517 const displayStatus = resourceDisplayStatus(row, rowState.status);
518 const color = applyStatusColor(displayStatus);
519 return (
520 <Box flexDirection="column">
521 <TaskRow
522 spinning={isInProgress(rowState.status)}
523 icon={
524 rowState.status === "pending"
525 ? glyphs.bullet
526 : rowState.status === "fail"
527 ? glyphs.error
528 : rowState.status === "adopted"
529 ? glyphs.adopt
530 : rowState.status === "orphaned"
531 ? glyphs.orphan
532 : glyphs.success
533 }
534 iconColor={color}
535 label={
536 <>
537 {row.action === "orphaned" ? (
538 <Text tone="muted">{row.id}</Text>
539 ) : (
540 row.id
541 )}
542 {row.id !== row.resourceType ? (
543 <Text tone="muted"> ({row.resourceType})</Text>
544 ) : null}
545 {row.detail ? <Text tone="muted"> · {row.detail}</Text> : null}
546 </>
547 }
548 detail={rowDetail(rowState.status, rowState.message)}
549 depth={row.depth}
550 >
551 {modeNote ? <Text tone="muted">({modeNote})</Text> : null}
552 <Text color={color}>{displayStatus}</Text>
553 {rowState.elapsedMs === undefined ? null : (
554 <Text tone="muted">({formatElapsed(rowState.elapsedMs)})</Text>
555 )}
556 </TaskRow>
557 {yaml}
558 </Box>
559 );
560}
561
562// ── Headers ───────────────────────────────────────────────────────────────
563
564function ReviewSummary(props: {
565 label: string;
566 summary: PlanSummaryCounts;
567}): JSX.Element {
568 const { counts, taskCounts, bindingChanges } = props.summary;
569 const parts = [
570 ...(
571 ["create", "update", "adopted", "delete", "orphaned", "replace"] as const
572 )
573 .filter((action) => counts[action] > 0)
574 .map((action) => ({
575 key: action,
576 label: `${counts[action]} to ${action}`,
577 color: namespaceStyle(action).color,
578 })),
579 ...(counts.noop > 0
580 ? [
581 {
582 key: "noop",
583 label: `${counts.noop} no change`,
584 color: theme.color.muted,
585 },
586 ]
587 : []),
588 ...(taskCounts.run > 0
589 ? [
590 {
591 key: "run",
592 label: `${taskCounts.run} to run`,
593 color: namespaceStyle("run").color,
594 },
595 ]
596 : []),
597 ...(taskCounts.delete > 0
598 ? [
599 {
600 key: "drop",
601 label: `${taskCounts.delete} to drop`,
602 color: namespaceStyle("delete").color,
603 },
604 ]
605 : []),
606 ...(taskCounts.noop > 0
607 ? [
608 {
609 key: "task-noop",
610 label: `${taskCounts.noop} tasks no change`,
611 color: theme.color.muted,
612 },
613 ]
614 : []),
615 ...(bindingChanges > 0
616 ? [
617 {
618 key: "bindings",
619 label: `${bindingChanges} binding changes`,
620 color: theme.color.info,
621 },
622 ]
623 : []),
624 ];
625 return (
626 <>
627 <SectionHeading>{props.label}</SectionHeading>
628 <Text tone="muted"> · </Text>
629 {parts.length === 0 ? (
630 <Text tone="muted">no changes</Text>
631 ) : (
632 parts.map((part, index) => (
633 <Box key={part.key}>
634 {index === 0 ? null : <Text tone="muted"> · </Text>}
635 <Text color={part.color}>{part.label}</Text>
636 </Box>
637 ))
638 )}
639 </>
640 );
641}
642
643const applySummaryOrder: readonly (ApplyStatus | "no change")[] = [
644 "fail",
645 "attaching",
646 "post-attach",
647 "pre-creating",
648 "creating",
649 "creating replacement",
650 "updating",
651 "adopting",
652 "deleting",
653 "orphaning",
654 "replacing",
655 "running",
656 "pending",
657 "created",
658 "updated",
659 "adopted",
660 "deleted",
661 "orphaned",
662 "replaced",
663 "ran",
664 "skipped",
665 "no change",
666];
667
668function ApplySummary(props: {
669 label: string;
670 rows: readonly PlanRow[];
671 tasks: ReadonlyMap<string, RowState>;
672}): JSX.Element {
673 const counts = new Map<ApplyStatus | "no change", number>();
674 for (const row of props.rows) {
675 const status = props.tasks.get(row.key)?.status ?? "pending";
676 const displayStatus = row.action === "noop" ? "no change" : status;
677 counts.set(displayStatus, (counts.get(displayStatus) ?? 0) + 1);
678 }
679 const parts = applySummaryOrder
680 .filter((status) => counts.has(status))
681 .map((status) => ({
682 status,
683 count: counts.get(status) ?? 0,
684 }));
685
686 return (
687 <>
688 <SectionHeading>{props.label}</SectionHeading>
689 <Text tone="muted"> · </Text>
690 {parts.length === 0 ? (
691 <Text tone="muted">no resources</Text>
692 ) : (
693 parts.map((part, index) => (
694 <Box key={part.status}>
695 {index === 0 ? null : <Text tone="muted"> · </Text>}
696 <Text color={applyStatusColor(part.status)}>
697 {part.count} {part.status}
698 </Text>
699 </Box>
700 ))
701 )}
702 </>
703 );
704}
705
706// ── Status helpers ────────────────────────────────────────────────────────
707
708const resourceDisplayStatus = (
709 row: ResourceRow,
710 status: ApplyStatus,
711): ApplyStatus | "no change" =>
712 row.action === "noop" && (status === "created" || status === "updated")
713 ? "no change"
714 : status;
715
716const taskLabel = (action: ActionVerb, status: ApplyStatus): string =>
717 action === "delete"
718 ? status === "deleted" || status === "orphaned"
719 ? status
720 : "drop"
721 : status === "ran"
722 ? action === "noop"
723 ? "skip"
724 : "ran"
725 : status === "running"
726 ? "running"
727 : status === "fail"
728 ? "fail"
729 : action === "noop"
730 ? "skip"
731 : "run";
732
733/** Static glyph for a task row; the running state renders a spinner instead. */
734function taskIcon(
735 action: ActionVerb,
736 status: ApplyStatus,
737 glyphs: ReturnType<typeof useGlyphs>,
738): string {
739 if (status === "fail") return glyphs.error;
740 if (status === "skipped") return glyphs.bullet;
741 if (status === "ran")
742 return action === "noop" ? glyphs.bullet : glyphs.success;
743 if (status === "deleted" || status === "orphaned") return glyphs.success;
744 if (action === "delete") return glyphs[actionStyle.delete.icon];
745 if (action === "noop") return glyphs[actionStyle.noop.icon];
746 return glyphs[actionStyle.run.icon];
747}
748
749function YamlLine({
750 line,
751 paddingLeft,
752}: {
753 readonly line: string;
754 readonly paddingLeft: number;
755}) {
756 const change = matchYamlChange(line);
757 const content = change?.content ?? line;
758 const key = matchYamlKey(content);
759 const removed = change?.marker === "-";
760 const added = change?.marker === "+";
761 return (
762 <DetailLine
763 paddingLeft={paddingLeft}
764 backgroundColor={
765 removed
766 ? theme.color.diffRemoveBackground
767 : added
768 ? theme.color.diffAddBackground
769 : undefined
770 }
771 >
772 <Box width={2} flexShrink={0}>
773 <Text color={removed ? theme.color.danger : theme.color.success}>
774 {change?.marker ?? " "}
775 </Text>
776 </Box>
777 <Box flexGrow={1}>
778 <Text wrap="truncate-end">
779 {key === undefined ? (
780 content
781 ) : (
782 <>
783 {key.indent}
784 <Text color={change === undefined ? theme.color.info : undefined}>
785 {key.key}
786 </Text>
787 {key.value}
788 </>
789 )}
790 </Text>
791 </Box>
792 </DetailLine>
793 );
794}
795
796function DetailLine({
797 paddingLeft,
798 backgroundColor,
799 children,
800}: {
801 readonly paddingLeft: number;
802 readonly backgroundColor?: string;
803 readonly children: ReactNode;
804}) {
805 const borderStyle = useBorderStyle();
806 return (
807 <Box paddingLeft={paddingLeft} width="100%">
808 <Box
809 width="100%"
810 paddingLeft={1}
811 borderStyle={borderStyle}
812 borderLeft
813 borderTop={false}
814 borderRight={false}
815 borderBottom={false}
816 borderColor={theme.color.muted}
817 borderDimColor
818 backgroundColor={backgroundColor}
819 >
820 {children}
821 </Box>
822 </Box>
823 );
824}
825
826// ── Static convenience ────────────────────────────────────────────────────