1/** @jsxImportSource @alchemy.run/sigil */
2import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as Scope from "effect/Scope";
7import type { Plan } from "../../../Plan.ts";
8import { type PlanStatusSession, Cli } from "../../../Report.ts";
9import { CliKit } from "../../CliKit/index.ts";
10import type { ApplyEvent } from "../../../Report.ts";
11import { formatElapsed } from "../../Format.ts";
12import { approvePlanScreen } from "./ApprovePlan.tsx";
13import { Plan as PlanComponent, PlanTree } from "./PlanView.tsx";
14
15/**
16 * UI choices that outlive a single apply session. `alchemy dev` opens a new
17 * session (and a new plan widget) on every hot reload; without this the
18 * widget would reappear after the user hid it with `p`.
19 */
20interface SessionPreferences {
21 planExpanded: boolean;
22}
23
24export const sigilCli = () =>
25 Layer.effect(
26 Cli,
27 Effect.map(CliKit, (cli) => {
28 const preferences: SessionPreferences = { planExpanded: true };
29 return Cli.of({
30 startPlanningSession: (label, detail, title) =>
31 startPlanningSession(cli, label, detail, title),
32 approvePlan: (plan, options) => approvePlan(cli, plan, options),
33 displayPlan: (plan, options) => displayPlan(cli, plan, options),
34 startApplySession: (plan, options) =>
35 startApplySession(cli, plan, options, preferences),
36 });
37 }),
38 );
39
40const approvePlan = Effect.fn(function* <P extends Plan>(
41 cli: CliKit["Service"],
42 plan: P,
43 options?: import("../../../Report.ts").PlanDisplayOptions,
44) {
45 // A dismissed screen means "declined"; NonInteractiveTerminal propagates
46 // typed to the command-level handlers (a friendly line, not a raw cause).
47 return yield* cli.prompt
48 .custom(approvePlanScreen(plan, options?.detailed))
49 .pipe(Effect.catchTag("TerminalCancelled", () => Effect.succeed(false)));
50});
51
52const displayPlan = Effect.fn(function* <P extends Plan>(
53 cli: CliKit["Service"],
54 plan: P,
55 options?: import("../../../Report.ts").PlanDisplayOptions,
56) {
57 const tree = new PlanTree(plan, {
58 detailed: options?.detailed,
59 mode: "review",
60 label: "Plan",
61 viewport: "full",
62 });
63 yield* cli.output.print(<PlanComponent tree={tree} />);
64});
65
66const startPlanningSession = Effect.fn(function* (
67 cli: CliKit["Service"],
68 label: string,
69 detail?: string,
70 title?: string,
71) {
72 const startedAt = yield* Clock.currentTimeMillis;
73 const scope = yield* Scope.make();
74 const progress = yield* cli.live
75 .progress({
76 label,
77 detail,
78 title: title === undefined ? undefined : `${label} · ${title}`,
79 spinning: false,
80 })
81 .pipe(Scope.provide(scope));
82 let closed = false;
83 const finish = (effect: Effect.Effect<void>) =>
84 Effect.suspend(() => {
85 if (closed) return Effect.void;
86 closed = true;
87 return effect.pipe(Effect.ensuring(Scope.close(scope, Exit.void)));
88 });
89 // Settle messages carry the total planning duration, mirroring the plain
90 // renderer's "(1.8s)" suffix.
91 const settled = (message: string | undefined) =>
92 message === undefined
93 ? Effect.succeed(undefined)
94 : Clock.currentTimeMillis.pipe(
95 Effect.map((now) => `${message} (${formatElapsed(now - startedAt)})`),
96 );
97 return {
98 update: (
99 nextLabel: string,
100 nextDetail?: string,
101 options?: { readonly spinning?: boolean },
102 ) =>
103 progress.update({
104 label: nextLabel,
105 detail: nextDetail,
106 title: title === undefined ? undefined : `${nextLabel} · ${title}`,
107 spinning: options?.spinning ?? true,
108 }),
109 succeed: (message?: string) =>
110 finish(Effect.flatMap(settled(message), progress.succeed)),
111 fail: (message?: string) =>
112 finish(Effect.flatMap(settled(message), progress.fail)),
113 close: finish(progress.close),
114 };
115});
116
117const startApplySession = Effect.fn(function* <P extends Plan>(
118 cli: CliKit["Service"],
119 plan: P,
120 options: import("../../../Report.ts").PlanDisplayOptions | undefined,
121 preferences: SessionPreferences,
122) {
123 // Detailed applies render their YAML diffs inline in the progress tree.
124 // One-shot operations persist the final render; dev keeps its live block
125 // mounted so logs remain static above the collapsible plan and keybar.
126 const labels = plan.destroy
127 ? {
128 active: "Destroying stack",
129 success: "Stack destroyed",
130 failure: "Destroy failed",
131 }
132 : plan.defaultMode === "local"
133 ? {
134 active: "Starting dev stack",
135 success: "Dev stack ready",
136 failure: "Dev startup failed",
137 }
138 : {
139 active: "Deploying stack",
140 success: "Stack deployed",
141 failure: "Deploy failed",
142 };
143 const progress = new PlanTree(plan, {
144 detailed: options?.detailed,
145 mode: "apply",
146 label: labels.active,
147 titleDetail: options?.stage,
148 busy: true,
149 expanded: preferences.planExpanded,
150 });
151 // Remember the collapse toggle as it happens, so the next hot-reload
152 // generation opens the way the user left this one.
153 progress.subscribe(() => {
154 preferences.planExpanded = progress.snapshot().expanded;
155 });
156 // The session outlives this effect — the caller settles it via `done` on
157 // every exit path (Apply.ts's onExit). live.open is Scope-bound, so give
158 // it a manually managed scope. One-shot operations close it in `done`;
159 // dev retains it after `done` and tears it down through `close` when the
160 // generation is interrupted (a reload or Ctrl+C), so a replaced
161 // generation never leaves its widget behind.
162 const scope = yield* Scope.make();
163 const live = yield* cli.live
164 .open(<PlanComponent tree={progress} collapsible={options?.dev} />, {
165 persistOnClose: !options?.dev,
166 })
167 .pipe(Scope.provide(scope));
168 const close = live.close.pipe(Effect.ensuring(Scope.close(scope, Exit.void)));
169 return {
170 done: (outcome) =>
171 Effect.sync(() => {
172 const outputReady = progress.snapshot().output !== undefined;
173 progress.finish(
174 outcome,
175 labels[outcome],
176 options?.dev && outcome === "success" && outputReady
177 ? "output"
178 : "plan",
179 );
180 if (!options?.dev) progress.setViewport("full");
181 }).pipe(Effect.andThen(options?.dev ? Effect.void : close)),
182 close,
183 emit: (event: ApplyEvent) => Effect.sync(() => progress.emit(event)),
184 setOutput: (value: unknown) => Effect.sync(() => progress.setOutput(value)),
185 } satisfies PlanStatusSession;
186});