// biome-ignore lint/style/useImportType: UMD global import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text } from "ink"; import type { ApplyEvent, ApplyStatus, StatusChangeEvent, } from "../../event.ts"; import type { IPlan } from "../../plan.ts"; interface ProgressEventSource { subscribe(listener: (event: ApplyEvent) => void): () => void; } interface PlanTask extends Required< Pick > { message?: string; updatedAt: number; } interface PlanProgressProps { source: ProgressEventSource; plan: IPlan; } export function PlanProgress(props: PlanProgressProps): React.JSX.Element { const { source, plan } = props; const spinner = useGlobalSpinner(); const [tasks, setTasks] = useState>(() => { // Initialize tasks from the plan with appropriate starting status const initialTasks = new Map(); const nodes = [ ...Object.entries(plan.resources), ...Object.entries(plan.deletions), ]; for (const [id, item] of nodes) { const planItem = item!; const status: ApplyStatus = planItem.action === "noop" ? "success" : "pending"; initialTasks.set(id, { id, type: planItem.resource.type, status, updatedAt: Date.now(), }); } return initialTasks; }); const unsubscribeRef = useRef void)>(null); useEffect(() => { unsubscribeRef.current?.(); unsubscribeRef.current = source.subscribe((event) => { setTasks((prev) => { const next = new Map(prev); const current = next.get(event.id); if (event.kind === "status-change") { if (!event.bindingId) { // Only handle resource-level events, ignore binding events const updated: PlanTask = { id: event.id, type: event.type, status: event.status, message: event.message ?? current?.message, updatedAt: Date.now(), }; next.set(event.id, updated); } } else if (event.kind === "annotate" && current) { next.set(event.id, { ...current, message: event.message, updatedAt: Date.now(), }); } return next; }); }); return () => { unsubscribeRef.current?.(); unsubscribeRef.current = null; }; }, [source]); // Reinitialize tasks when plan changes useEffect(() => { setTasks(() => { const initialTasks = new Map(); const nodes = [ ...Object.entries(plan.resources), ...Object.entries(plan.deletions), ]; for (const [id, item] of nodes) { const planItem = item!; const status: ApplyStatus = planItem.action === "noop" ? "success" : "pending"; initialTasks.set(id, { id, type: planItem.resource.type, status, updatedAt: Date.now(), }); } return initialTasks; }); }, [plan]); const rows = useMemo( () => Array.from(tasks.values()).sort((a, b) => { // First sort by status priority const priorityDiff = statusPriority(a.status) - statusPriority(b.status); if (priorityDiff !== 0) return priorityDiff; // Then sort by ID for consistent ordering within same priority return a.id.localeCompare(b.id); }), [tasks], ); return ( {rows.map((task) => { const color = statusColor(task.status); const icon = statusIcon(task.status, spinner); return ( {icon} {task.id} ({task.type}) {task.status} {task.message ? • {task.message} : null} ); })} ); } function statusPriority(status: ApplyStatus): number { switch (status) { case "success": case "created": case "updated": case "deleted": return 0; // highest priority (success) case "fail": return 1; case "creating": case "updating": case "deleting": return 2; // in progress case "pending": return 3; // lowest priority (pending) default: return 4; } } function statusColor(status: ApplyStatus): Parameters[0]["color"] { switch (status) { case "pending": return "gray"; case "creating": case "created": return "green"; case "updating": case "updated": return "yellow"; case "deleting": case "deleted": return "red"; case "success": return "green"; case "fail": return "redBright"; default: return undefined; } } function statusIcon(status: ApplyStatus, spinnerChar: string): string { if (isInProgress(status)) return spinnerChar; if (status === "fail") return "✗"; return "✓"; // created/updated/deleted/success } function isInProgress(status: ApplyStatus): boolean { return ( status === "pending" || status === "creating" || status === "updating" || status === "deleting" ); } 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]; }