/** @jsxImportSource react */ import { useEffect, useMemo, useRef, useState, type JSX } from "react"; import { Box, Text } from "ink"; import type { CRUD, Plan, ActionApply, ActionDelete } from "../../../Plan.ts"; import type { ApplyEvent, ApplyStatus, StatusChangeEvent, } from "../../Event.ts"; import { buildNamespaceTree, flattenTree, type FlattenedItem, type ActionVerb, } from "../../NamespaceTree.ts"; import { formatModeNote } from "../../ModeTag.ts"; import type { ProviderMode } from "../../../ProviderMode.ts"; interface ProgressEventSource { subscribe(listener: (event: ApplyEvent) => void): () => void; } interface PlanTask extends Required< Pick > { key: string; message?: string; updatedAt: number; } interface PlanProgressProps { source: ProgressEventSource; plan: Plan; } type PlanItem = CRUD | NonNullable; export type ProgressRow = | { key: string; type: "namespace"; id: string; depth: number; action: FlattenedItem["action"]; } | { key: string; type: "resource"; id: string; depth: number; resourceType: string; action: CRUD["action"]; /** For `noop` resources, persisted state status to show instead of `pending`. */ persistedApplyStatus?: "created" | "updated"; /** Resolved provider mode; `undefined` for mode-agnostic providers. */ providerMode?: ProviderMode; /** On mode-switch replacements, the old generation's stamped mode. */ fromProviderMode?: ProviderMode; } | { key: string; type: "task"; id: string; depth: number; actionType: string; action: ActionVerb; }; const getTaskKey = (item: FlattenedItem) => item.path.join("/"); type ResourceProgressRow = Extract; export const buildProgressRows = (plan: Plan): ProgressRow[] => { const items = [ ...Object.values(plan.resources), ...Object.values(plan.deletions).filter( (item): item is NonNullable => item !== undefined, ), ] as PlanItem[]; const taskItems = [ ...Object.values(plan.actions ?? {}), ...Object.values(plan.actionDeletions ?? {}), ].filter((t): t is ActionApply | ActionDelete => t !== undefined); const tree = buildNamespaceTree(items, taskItems); return flattenTree(tree) .filter((item) => item.type !== "binding") .map((item) => { if (item.type === "namespace") { return { key: getTaskKey(item), type: "namespace" as const, id: item.id, depth: item.depth, action: item.action, }; } if (item.type === "action") { return { key: getTaskKey(item), type: "task" as const, id: item.id, depth: item.depth, actionType: item.actionType ?? "unknown", action: item.action as ActionVerb, }; } return { key: getTaskKey(item), type: "resource" as const, id: item.id, depth: item.depth, resourceType: item.resourceType ?? "unknown", action: item.action as CRUD["action"], providerMode: item.providerMode, fromProviderMode: item.fromProviderMode, persistedApplyStatus: item.action === "noop" ? (() => { const crud = findCrudByLogicalId(plan, item.id); return crud?.action === "noop" ? crud.state.status : undefined; })() : undefined, }; }); }; const buildLogicalIdIndex = (rows: ProgressRow[]) => { const index = new Map(); for (const row of rows) { if (row.type !== "resource" && row.type !== "task") continue; const keys = index.get(row.id); if (keys) { keys.push(row.key); } else { index.set(row.id, [row.key]); } } return index; }; export function toPlanTask(id: string, planItem: PlanItem): PlanTask; export function toPlanTask(row: ResourceProgressRow): PlanTask; export function toPlanTask( rowOrId: ResourceProgressRow | string, planItem?: PlanItem, ): PlanTask { if (typeof rowOrId === "string") { return { key: rowOrId, id: rowOrId, type: planItem!.resource.Type, status: planItem!.action === "noop" ? planItem!.state.status : "pending", updatedAt: Date.now(), }; } return { key: rowOrId.key, id: rowOrId.id, type: rowOrId.resourceType, status: rowOrId.action === "noop" ? (rowOrId.persistedApplyStatus ?? "created") : "pending", updatedAt: Date.now(), }; } const buildInitialTasks = (rows: ProgressRow[]) => new Map( rows.flatMap((row) => row.type === "resource" ? [[row.key, toPlanTask(row)]] : row.type === "task" ? [ [ row.key, { key: row.key, id: row.id, type: row.actionType, // `noop` tasks are skipped — render as gray `•` from the start // rather than briefly flashing the `ran` cyan styling. status: row.action === "noop" ? ("skipped" as ApplyStatus) : ("pending" as ApplyStatus), updatedAt: Date.now(), }, ], ] : [], ), ); export function PlanProgress(props: PlanProgressProps): JSX.Element { const { source, plan } = props; const spinner = useGlobalSpinner(); const rows = useMemo(() => buildProgressRows(plan), [plan]); const logicalIdIndex = useMemo(() => buildLogicalIdIndex(rows), [rows]); const [tasks, setTasks] = useState>(() => buildInitialTasks(rows), ); const unsubscribeRef = useRef void)>(null); useEffect(() => { unsubscribeRef.current?.(); unsubscribeRef.current = source.subscribe((event) => { setTasks((prev) => { const next = new Map(prev); const keys = logicalIdIndex.get(event.id) ?? []; if (event.kind === "status-change") { if (!event.bindingId) { for (const key of keys) { const current = next.get(key); next.set(key, { key, id: event.id, type: event.type, status: event.status, message: event.message ?? current?.message, updatedAt: Date.now(), }); } } } else { for (const key of keys) { const current = next.get(key); if (!current) continue; next.set(key, { ...current, message: event.message, updatedAt: Date.now(), }); } } return next; }); }); return () => { unsubscribeRef.current?.(); unsubscribeRef.current = null; }; }, [logicalIdIndex, source]); useEffect(() => { setTasks(buildInitialTasks(rows)); }, [rows]); return ( {rows.map((row) => { const indent = " ".repeat(row.depth); if (row.type === "namespace") { return ( {indent} {row.id} ); } if (row.type === "task") { const t = tasks.get(row.key); const status: ApplyStatus = t?.status ?? (row.action === "noop" ? "ran" : "pending"); const color = statusColor(status); const icon = taskIcon(row.action, status, spinner); const label = row.action === "delete" ? status === "deleted" || status === "retained" ? status : "drop" : status === "ran" ? row.action === "noop" ? "skip" : "ran" : status === "running" ? "running" : status === "fail" ? "fail" : row.action === "noop" ? "skip" : "run"; return ( {indent} {icon} {row.id} ({row.actionType}) {label} {" "} [action] {t?.message ? ( • {t.message} ) : null} ); } const task = tasks.get(row.key) ?? toPlanTask(row); const displayStatus = getDisplayStatus(row, task.status); const color = statusColor(displayStatus); const icon = statusIcon(task.status, spinner); const modeNote = formatModeNote({ mode: row.providerMode, priorMode: row.fromProviderMode, defaultMode: plan.defaultMode, }); return ( {indent} {icon} {task.id} ({task.type}) {modeNote ? ({modeNote}) : null} {displayStatus} {task.message ? ( • {task.message} ) : null} ); })} ); } function getDisplayStatus( row: ResourceProgressRow, status: ApplyStatus, ): ApplyStatus | "no change" { if (row.action === "noop" && (status === "created" || status === "updated")) { return "no change"; } return status; } function statusColor( status: ApplyStatus | "no change", ): Parameters[0]["color"] { switch (status) { case "no change": return "gray"; case "pending": return "gray"; case "creating": case "created": return "green"; case "updating": case "updated": return "yellow"; case "deleting": case "deleted": return "red"; case "retained": return "gray"; case "running": case "ran": return "cyan"; case "skipped": return "gray"; case "fail": return "redBright"; default: return undefined; } } function taskIcon( action: ActionVerb, status: ApplyStatus, spinnerChar: string, ): string { if (status === "running") return spinnerChar; if (status === "fail") return "✗"; if (status === "skipped") return "•"; if (status === "ran") return action === "noop" ? "•" : "✓"; if (status === "deleted" || status === "retained") return "✓"; if (action === "delete") return "-"; if (action === "noop") return "•"; return "λ"; } function statusIcon(status: ApplyStatus, spinnerChar: string): string { if (isInProgress(status)) return spinnerChar; if (status === "fail") return "✗"; return "✓"; // created/updated/deleted/replaced/etc. } function isInProgress(status: ApplyStatus): boolean { return ( status === "pending" || status === "creating" || status === "updating" || status === "deleting" || status === "running" ); } const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; function useGlobalSpinner(intervalMs = 80): string { const [index, setIndex] = useState(0); useEffect(() => { const timer = setInterval(() => { setIndex((i) => (i + 1) % spinnerFrames.length); }, intervalMs); return () => clearInterval(timer); }, [intervalMs]); return spinnerFrames[index]; } const findCrudByLogicalId = ( plan: Plan, logicalId: string, ): CRUD | undefined => { for (const node of Object.values(plan.resources)) { if (node.resource.LogicalId === logicalId) { return node; } } for (const node of Object.values(plan.deletions)) { if (node?.resource.LogicalId === logicalId) { return node; } } return undefined; };