| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import { useMemo, useState } from "@alchemy.run/sigil/react"; |
| 3 | import type { JSX } from "react"; |
| 4 | import { |
| 5 | ChoiceGroup, |
| 6 | Box, |
| 7 | KeyBar, |
| 8 | Text, |
| 9 | useGlyphs, |
| 10 | useKeyGlyphs, |
| 11 | useTerminalInput, |
| 12 | } from "../ui/index.ts"; |
| 13 | import { Screen, theme, type ScreenController } from "../../CliKit/index.ts"; |
| 14 | import type { Plan as AlchemyPlan } from "../../../Plan.ts"; |
| 15 | import { Plan, PlanTree } from "./PlanView.tsx"; |
| 16 | |
| 17 | export interface ApprovePlanProps { |
| 18 | plan: AlchemyPlan; |
| 19 | detailed?: boolean; |
| 20 | controller: ScreenController<boolean>; |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * Plan approval prompt: the plan tree followed by an operation-specific |
| 25 | * action and Cancel. Escape cancels; Ctrl+C is handled centrally by the |
| 26 | * screen runner. |
| 27 | */ |
| 28 | export function ApprovePlan(props: ApprovePlanProps): JSX.Element { |
| 29 | const { plan, detailed = false, controller } = props; |
| 30 | // Destruction is the risky path: require an explicit move to Destroy before |
| 31 | // Enter can remove or orphan resources. Deploy approvals remain action-first. |
| 32 | const [approved, setApproved] = useState(!plan.destroy); |
| 33 | const action = plan.destroy ? "Destroy" : "Deploy"; |
| 34 | const glyphs = useGlyphs(); |
| 35 | const keys = useKeyGlyphs(); |
| 36 | const tree = useMemo( |
| 37 | () => |
| 38 | new PlanTree(plan, { |
| 39 | detailed, |
| 40 | mode: "review", |
| 41 | label: action, |
| 42 | }), |
| 43 | [plan, detailed, action], |
| 44 | ); |
| 45 | const complete = (answer: boolean) => { |
| 46 | const verdict = ( |
| 47 | <Text color={answer ? theme.color.success : theme.color.danger}> |
| 48 | {answer ? glyphs.success : glyphs.error} {answer ? action : "Cancelled"} |
| 49 | </Text> |
| 50 | ); |
| 51 | // On approval the plan disappears — the apply progress that follows |
| 52 | // shows every row again. A declined plan stays on screen (complete, |
| 53 | // unscrolled) so the user can review what they just turned down. |
| 54 | if (!answer) tree.setViewport("full"); |
| 55 | const summary = answer ? ( |
| 56 | verdict |
| 57 | ) : ( |
| 58 | <Box flexDirection="column"> |
| 59 | <Plan tree={tree} /> |
| 60 | {verdict} |
| 61 | </Box> |
| 62 | ); |
| 63 | controller.submit(answer, summary); |
| 64 | }; |
| 65 | |
| 66 | useTerminalInput((input, key) => { |
| 67 | if (key.enter) complete(approved); |
| 68 | else if (key.escape) controller.cancel(); |
| 69 | else if (key.ctrl || key.meta) return; |
| 70 | else if (input.toLowerCase() === "y") complete(true); |
| 71 | else if (input.toLowerCase() === "n") complete(false); |
| 72 | }); |
| 73 | |
| 74 | const prompt = ( |
| 75 | <Box flexDirection="column"> |
| 76 | <Text bold>{action}?</Text> |
| 77 | <ChoiceGroup |
| 78 | value={approved} |
| 79 | choices={[ |
| 80 | { value: true, label: action }, |
| 81 | { value: false, label: "Cancel" }, |
| 82 | ]} |
| 83 | onChange={setApproved} |
| 84 | /> |
| 85 | <KeyBar |
| 86 | keys={[ |
| 87 | [keys.upDown, "scroll plan"], |
| 88 | [keys.leftRight, "choose"], |
| 89 | [keys.enter, "confirm"], |
| 90 | [keys.escape, "cancel"], |
| 91 | ]} |
| 92 | /> |
| 93 | </Box> |
| 94 | ); |
| 95 | |
| 96 | return <Plan tree={tree} footer={prompt} />; |
| 97 | } |
| 98 | |
| 99 | export const approvePlanScreen = (plan: AlchemyPlan, detailed = false) => |
| 100 | Screen.make<boolean>("plan approval", (controller) => ( |
| 101 | <ApprovePlan plan={plan} detailed={detailed} controller={controller} /> |
| 102 | )); |