1/** @jsxImportSource @alchemy.run/sigil */
2import {
3 useEffect,
4 useMemo,
5 useState,
6 useSyncExternalStore,
7} from "@alchemy.run/sigil/react";
8import type { JSX } from "react";
9import * as Cause from "effect/Cause";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import type * as Fiber from "effect/Fiber";
14import * as Option from "effect/Option";
15import type {
16 InvalidStatePath,
17 StateStoreError,
18} from "../../../State/index.ts";
19import {
20 Box,
21 KeyBar,
22 Spinner,
23 Text,
24 TextField,
25 Viewport,
26 useBorderStyle,
27 useCliEnvironment,
28 useKeyGlyphs,
29 useTerminalInput,
30 useTerminalSize,
31} from "../ui/index.ts";
32import { Screen, theme, type ScreenController } from "../../CliKit/index.ts";
33import { formatYamlLines, matchYamlKey } from "../../PropertyDiff.ts";
34
35export type StateFileRef =
36 | {
37 readonly kind: "resource";
38 readonly stack: string;
39 readonly stage: string;
40 readonly fqn: string;
41 }
42 | {
43 readonly kind: "output";
44 readonly stack: string;
45 readonly stage: string;
46 };
47
48export type StateExplorerError = InvalidStatePath | StateStoreError;
49
50export interface StateExplorerSource {
51 readonly backend: string;
52 readonly listStacks: Effect.Effect<ReadonlyArray<string>, StateExplorerError>;
53 readonly listStages: (
54 stack: string,
55 ) => Effect.Effect<ReadonlyArray<string>, StateExplorerError>;
56 readonly listResources: (
57 stack: string,
58 stage: string,
59 ) => Effect.Effect<ReadonlyArray<string>, StateExplorerError>;
60 readonly readFile: (
61 file: StateFileRef,
62 ) => Effect.Effect<unknown, StateExplorerError>;
63 readonly deleteNodes: (
64 nodes: ReadonlyArray<StateBrowserNode>,
65 ) => Effect.Effect<void, StateExplorerError>;
66}
67
68export type StateBrowserNode =
69 | {
70 readonly kind: "stack";
71 readonly id: string;
72 readonly name: string;
73 readonly path: string;
74 readonly stack: string;
75 }
76 | {
77 readonly kind: "stage";
78 readonly id: string;
79 readonly name: string;
80 readonly path: string;
81 readonly stack: string;
82 readonly stage: string;
83 }
84 | {
85 readonly kind: "namespace";
86 readonly id: string;
87 readonly name: string;
88 readonly path: string;
89 readonly stack: string;
90 readonly stage: string;
91 readonly children: ReadonlyArray<StateBrowserNode>;
92 }
93 | {
94 readonly kind: "resource";
95 readonly id: string;
96 readonly name: string;
97 readonly path: string;
98 readonly file: StateFileRef & { readonly kind: "resource" };
99 }
100 | {
101 readonly kind: "output";
102 readonly id: string;
103 readonly name: string;
104 readonly path: string;
105 readonly file: StateFileRef & { readonly kind: "output" };
106 };
107
108type LoadState<Value> =
109 | { readonly status: "idle" }
110 | { readonly status: "loading" }
111 | { readonly status: "ready"; readonly value: Value }
112 | { readonly status: "error"; readonly message: string };
113
114interface ExplorerSnapshot {
115 readonly root: LoadState<ReadonlyArray<StateBrowserNode>>;
116 readonly children: ReadonlyMap<
117 string,
118 LoadState<ReadonlyArray<StateBrowserNode>>
119 >;
120 readonly files: ReadonlyMap<string, LoadState<unknown>>;
121}
122
123const errorMessage = (error: StateExplorerError) =>
124 error._tag === "InvalidStatePath"
125 ? `${error.path}: ${error.reason}`
126 : error.message;
127
128const causeMessage = (cause: Cause.Cause<StateExplorerError>) =>
129 Option.match(Cause.findErrorOption(cause), {
130 onSome: errorMessage,
131 onNone: () => {
132 const defect = Cause.squash(cause);
133 return defect instanceof Error ? defect.message : String(defect);
134 },
135 });
136
137/** Mutable async cache: every state-store read is initiated by a selection. */
138export class StateExplorerStore {
139 private state: ExplorerSnapshot = {
140 root: { status: "idle" },
141 children: new Map(),
142 files: new Map(),
143 };
144 private readonly listeners = new Set<() => void>();
145 private readonly fibers = new Set<Fiber.Fiber<unknown, unknown>>();
146
147 constructor(
148 readonly source: StateExplorerSource,
149 private readonly services: Context.Context<never> = Context.empty(),
150 ) {}
151
152 readonly subscribe = (listener: () => void) => {
153 this.listeners.add(listener);
154 return () => this.listeners.delete(listener);
155 };
156 readonly snapshot = () => this.state;
157 private commit(next: ExplorerSnapshot) {
158 this.state = next;
159 for (const listener of this.listeners) listener();
160 }
161
162 /**
163 * Fork the effect in the CLI's ambient services and hand its exit to the
164 * caller. A fiber the store no longer tracks — dropped by `refresh` or
165 * `dispose` — is stale, and its result is discarded even if it managed to
166 * complete before the interrupt landed.
167 */
168 private run<A>(
169 effect: Effect.Effect<A, StateExplorerError>,
170 handlers: {
171 readonly onSuccess: (value: A) => void;
172 readonly onFailure: (message: string) => void;
173 },
174 ) {
175 const fiber = Effect.runForkWith(this.services)(effect);
176 this.fibers.add(fiber);
177 fiber.addObserver((exit) => {
178 if (!this.fibers.delete(fiber)) return;
179 if (Exit.isSuccess(exit)) handlers.onSuccess(exit.value);
180 else if (!Cause.hasInterrupts(exit.cause))
181 handlers.onFailure(causeMessage(exit.cause));
182 });
183 }
184
185 private interruptAll() {
186 for (const fiber of this.fibers) fiber.interruptUnsafe();
187 this.fibers.clear();
188 }
189
190 readonly loadRoot = () => {
191 if (this.state.root.status !== "idle") return;
192 this.commit({ ...this.state, root: { status: "loading" } });
193 this.run(this.source.listStacks, {
194 onSuccess: (stacks) => {
195 this.commit({
196 ...this.state,
197 root: {
198 status: "ready",
199 value: [...stacks].sort().map((stack) => ({
200 kind: "stack",
201 id: `stack:${stack}`,
202 name: stack,
203 path: stack,
204 stack,
205 })),
206 },
207 });
208 },
209 onFailure: (message) => {
210 this.commit({ ...this.state, root: { status: "error", message } });
211 },
212 });
213 };
214
215 readonly refresh = () => {
216 this.interruptAll();
217 this.commit({
218 root: { status: "idle" },
219 children: new Map(),
220 files: new Map(),
221 });
222 this.loadRoot();
223 };
224
225 /**
226 * Re-list only what a deletion could have changed — the parent listing of
227 * each target — and drop cached files under it. Every other cached column
228 * survives, so the explorer keeps its position instead of collapsing to
229 * the stack list the way `refresh` does.
230 */
231 readonly invalidate = (targets: ReadonlyArray<StateBrowserNode>) => {
232 const children = new Map(this.state.children);
233 const files = new Map(this.state.files);
234 const parents = new Map<string, StateBrowserNode | undefined>();
235 for (const target of targets) {
236 for (const id of files.keys()) {
237 if (id.slice(id.indexOf(":") + 1).startsWith(`${target.path}/`))
238 files.delete(id);
239 }
240 if (target.kind === "stack") {
241 for (const id of children.keys()) {
242 if (
243 id === `stack:${target.stack}` ||
244 id.startsWith(`stage:${target.stack}/`)
245 )
246 children.delete(id);
247 }
248 parents.set("root", undefined);
249 } else if (target.kind === "stage") {
250 children.delete(target.id);
251 parents.set(`stack:${target.stack}`, {
252 kind: "stack",
253 id: `stack:${target.stack}`,
254 name: target.stack,
255 path: target.stack,
256 stack: target.stack,
257 });
258 } else if (target.kind !== "output") {
259 const { stack, stage } =
260 target.kind === "resource" ? target.file : target;
261 parents.set(`stage:${stack}/${stage}`, {
262 kind: "stage",
263 id: `stage:${stack}/${stage}`,
264 name: stage,
265 path: `${stack}/${stage}`,
266 stack,
267 stage,
268 });
269 }
270 }
271 for (const id of parents.keys()) {
272 if (id !== "root") children.delete(id);
273 }
274 this.commit({
275 ...this.state,
276 root: parents.has("root") ? { status: "idle" } : this.state.root,
277 children,
278 files,
279 });
280 for (const parent of parents.values()) {
281 if (parent === undefined) this.loadRoot();
282 else this.loadChildren(parent);
283 }
284 };
285
286 readonly loadChildren = (node: StateBrowserNode) => {
287 if (
288 node.kind === "namespace" ||
289 node.kind === "resource" ||
290 node.kind === "output"
291 ) {
292 return;
293 }
294 if ((this.state.children.get(node.id)?.status ?? "idle") !== "idle") return;
295 const children = new Map(this.state.children);
296 children.set(node.id, { status: "loading" });
297 this.commit({ ...this.state, children });
298
299 const request =
300 node.kind === "stack"
301 ? Effect.map(this.source.listStages(node.stack), (stages) =>
302 [...stages].sort().map((stage): StateBrowserNode => ({
303 kind: "stage",
304 id: `stage:${node.stack}/${stage}`,
305 name: stage,
306 path: `${node.stack}/${stage}`,
307 stack: node.stack,
308 stage,
309 })),
310 )
311 : Effect.map(
312 this.source.listResources(node.stack, node.stage!),
313 (fqns) => buildStageNodes(node.stack, node.stage!, fqns),
314 );
315 this.run(request, {
316 onSuccess: (value) => {
317 const next = new Map(this.state.children);
318 next.set(node.id, { status: "ready", value });
319 this.commit({ ...this.state, children: next });
320 },
321 onFailure: (message) => {
322 const next = new Map(this.state.children);
323 next.set(node.id, { status: "error", message });
324 this.commit({ ...this.state, children: next });
325 },
326 });
327 };
328
329 readonly loadFile = (node: StateBrowserNode) => {
330 if (node.kind !== "resource" && node.kind !== "output") return;
331 if ((this.state.files.get(node.id)?.status ?? "idle") !== "idle") return;
332 const files = new Map(this.state.files);
333 files.set(node.id, { status: "loading" });
334 this.commit({ ...this.state, files });
335 this.run(this.source.readFile(node.file), {
336 onSuccess: (value) => {
337 const next = new Map(this.state.files);
338 next.set(node.id, { status: "ready", value });
339 this.commit({ ...this.state, files: next });
340 },
341 onFailure: (message) => {
342 const next = new Map(this.state.files);
343 next.set(node.id, { status: "error", message });
344 this.commit({ ...this.state, files: next });
345 },
346 });
347 };
348
349 readonly deleteNodes = (
350 targets: ReadonlyArray<StateBrowserNode>,
351 handlers: {
352 readonly onSuccess: () => void;
353 readonly onFailure: (message: string) => void;
354 },
355 ) => this.run(this.source.deleteNodes(targets), handlers);
356
357 readonly dispose = () => {
358 this.interruptAll();
359 this.listeners.clear();
360 };
361}
362
363interface MutableNamespace {
364 readonly name: string;
365 readonly path: string;
366 readonly children: Map<string, MutableNamespace>;
367 readonly files: StateBrowserNode[];
368}
369
370/** Convert listed FQNs to namespace columns without reading any state values. */
371export const buildStageNodes = (
372 stack: string,
373 stage: string,
374 fqns: ReadonlyArray<string>,
375): ReadonlyArray<StateBrowserNode> => {
376 const root: MutableNamespace = {
377 name: "",
378 path: `${stack}/${stage}`,
379 children: new Map(),
380 files: [],
381 };
382 for (const fqn of [...fqns].sort()) {
383 const parts = fqn.split("/").filter(Boolean);
384 let current = root;
385 for (const part of parts.slice(0, -1)) {
386 let child = current.children.get(part);
387 if (child === undefined) {
388 child = {
389 name: part,
390 path: `${current.path}/${part}`,
391 children: new Map(),
392 files: [],
393 };
394 current.children.set(part, child);
395 }
396 current = child;
397 }
398 const name = parts.at(-1) ?? fqn;
399 current.files.push({
400 kind: "resource",
401 id: `resource:${stack}/${stage}/${fqn}`,
402 name,
403 path: `${stack}/${stage}/${fqn}`,
404 file: { kind: "resource", stack, stage, fqn },
405 });
406 }
407
408 const freeze = (namespace: MutableNamespace): StateBrowserNode[] => [
409 ...[...namespace.children.values()]
410 .sort((a, b) => a.name.localeCompare(b.name))
411 .map((child): StateBrowserNode => ({
412 kind: "namespace",
413 id: `namespace:${child.path}`,
414 name: child.name,
415 path: child.path,
416 stack,
417 stage,
418 children: freeze(child),
419 })),
420 ...namespace.files,
421 ];
422 return [
423 ...freeze(root),
424 {
425 kind: "output",
426 id: `output:${stack}/${stage}`,
427 name: "output",
428 path: `${stack}/${stage}/output`,
429 file: { kind: "output", stack, stage },
430 },
431 ];
432};
433
434const nodeStyle: Record<
435 StateBrowserNode["kind"],
436 { readonly color: string; readonly icon: string; readonly asciiIcon: string }
437> = {
438 stack: { color: theme.color.brand, icon: "◆", asciiIcon: "#" },
439 stage: { color: theme.color.warning, icon: "●", asciiIcon: "@" },
440 namespace: { color: theme.color.accent, icon: "▸", asciiIcon: ">" },
441 resource: { color: theme.color.info, icon: "▪", asciiIcon: "-" },
442 output: { color: theme.color.success, icon: "◇", asciiIcon: "=" },
443};
444
445const childrenFor = (
446 node: StateBrowserNode,
447 state: ExplorerSnapshot,
448): LoadState<ReadonlyArray<StateBrowserNode>> | undefined =>
449 node.kind === "namespace"
450 ? { status: "ready", value: node.children }
451 : node.kind === "stack" || node.kind === "stage"
452 ? (state.children.get(node.id) ?? { status: "idle" })
453 : undefined;
454
455interface BrowserColumn {
456 readonly id: string;
457 readonly title: string;
458 readonly state: LoadState<ReadonlyArray<StateBrowserNode>>;
459}
460
461const buildColumns = (
462 state: ExplorerSnapshot,
463 selection: ReadonlyArray<string>,
464): BrowserColumn[] => {
465 const columns: BrowserColumn[] = [
466 { id: "root", title: "Stacks", state: state.root },
467 ];
468 let current = state.root;
469 for (let depth = 0; depth < selection.length; depth++) {
470 if (current.status !== "ready") break;
471 const selected = current.value.find((node) => node.id === selection[depth]);
472 if (selected === undefined) break;
473 const children = childrenFor(selected, state);
474 if (children === undefined) break;
475 columns.push({
476 id: selected.id,
477 title:
478 selected.kind === "stack"
479 ? "Stages"
480 : selected.kind === "stage"
481 ? "State"
482 : selected.name,
483 state: children,
484 });
485 current = children;
486 }
487 return columns;
488};
489
490type ColumnProps = {
491 readonly column: BrowserColumn;
492 readonly selected: string | undefined;
493 readonly focused: boolean;
494 readonly height: number;
495 readonly query: string;
496 readonly marked: ReadonlySet<string>;
497};
498
499function Column({
500 column,
501 selected,
502 focused,
503 height,
504 query,
505 marked,
506}: ColumnProps) {
507 const { unicode } = useCliEnvironment();
508 const borderStyle = useBorderStyle();
509 if (column.state.status === "idle" || column.state.status === "loading") {
510 return <Spinner label="Loading" />;
511 }
512 if (column.state.status === "error") {
513 return <Text tone="danger">{column.state.message}</Text>;
514 }
515 const needle = query.trim().toLowerCase();
516 const items =
517 needle === ""
518 ? column.state.value
519 : column.state.value.filter((node) =>
520 node.path.toLowerCase().includes(needle),
521 );
522 const selectedIndex = Math.max(
523 0,
524 items.findIndex((node) => node.id === selected),
525 );
526 return (
527 <Viewport
528 items={items}
529 cursor={selectedIndex}
530 height={height}
531 getKey={(node) => node.id}
532 empty={<Text tone="muted">No matches.</Text>}
533 renderItem={(node) => {
534 const active = node.id === selected;
535 const style = nodeStyle[node.kind];
536 return (
537 <Box
538 width="100%"
539 paddingLeft={active ? 0 : 1}
540 borderStyle={borderStyle}
541 borderLeft={active}
542 borderRight={false}
543 borderTop={false}
544 borderBottom={false}
545 borderColor={focused ? theme.color.accent : theme.color.muted}
546 >
547 <Box width={2} flexShrink={0}>
548 <Text color={style.color}>
549 {marked.has(node.id)
550 ? unicode
551 ? "● "
552 : "* "
553 : `${unicode ? style.icon : style.asciiIcon} `}
554 </Text>
555 </Box>
556 <Text
557 bold={active}
558 color={
559 active
560 ? focused
561 ? theme.color.accentBright
562 : theme.color.emphasis
563 : undefined
564 }
565 wrap="truncate-end"
566 >
567 {node.name}
568 </Text>
569 {(node.kind === "stack" ||
570 node.kind === "stage" ||
571 node.kind === "namespace") && (
572 <Box flexGrow={1} justifyContent="flex-end">
573 <Text tone="muted">{unicode ? "›" : ">"}</Text>
574 </Box>
575 )}
576 </Box>
577 );
578 }}
579 />
580 );
581}
582
583type YamlLineProps = { readonly line: string };
584
585function YamlLine({ line }: YamlLineProps) {
586 const key = matchYamlKey(line);
587 if (key !== undefined) {
588 return (
589 <Text wrap="truncate-end">
590 {key.indent}
591 <Text color={theme.color.info}>{key.key}</Text>
592 <YamlValue value={key.value} />
593 </Text>
594 );
595 }
596 return (
597 <Text wrap="truncate-end">
598 <YamlValue value={line} />
599 </Text>
600 );
601}
602
603type YamlValueProps = { readonly value: string };
604
605function YamlValue({ value }: YamlValueProps) {
606 const trimmed = value.trimStart();
607 const color = /^(["'])/.test(trimmed)
608 ? theme.color.success
609 : /^(true|false)/.test(trimmed)
610 ? theme.color.accent
611 : /^-?\d/.test(trimmed)
612 ? theme.color.warning
613 : trimmed.startsWith("null")
614 ? theme.color.danger
615 : undefined;
616 return <Text color={color}>{value}</Text>;
617}
618
619type PreviewProps = {
620 readonly node: StateBrowserNode | undefined;
621 readonly state: LoadState<unknown> | undefined;
622 readonly lines: ReadonlyArray<string> | undefined;
623 readonly offset: number;
624 readonly height: number;
625};
626
627function Preview({ node, state, lines = [], offset, height }: PreviewProps) {
628 if (
629 node === undefined ||
630 (node.kind !== "resource" && node.kind !== "output")
631 ) {
632 return <Text tone="muted">Select a state file to preview it.</Text>;
633 }
634 if (
635 state === undefined ||
636 state.status === "idle" ||
637 state.status === "loading"
638 ) {
639 return <Spinner label="Reading state" detail={node.name} />;
640 }
641 if (state.status === "error")
642 return <Text tone="danger">{state.message}</Text>;
643 if (state.value === undefined) {
644 return <Text tone="muted">No stored value.</Text>;
645 }
646 const bodyHeight = Math.max(1, height - 2);
647 const start = Math.max(
648 0,
649 Math.min(offset, Math.max(0, lines.length - bodyHeight)),
650 );
651 return (
652 <Box flexDirection="column" height={height}>
653 <Text bold color={nodeStyle[node.kind].color} wrap="truncate-middle">
654 {node.path}
655 </Text>
656 <Box flexDirection="column">
657 {lines.slice(start, start + bodyHeight).map((line, index) => (
658 <YamlLine key={`${start + index}:${line}`} line={line} />
659 ))}
660 </Box>
661 </Box>
662 );
663}
664
665function StateExplorer({
666 store,
667 controller,
668}: {
669 readonly store: StateExplorerStore;
670 readonly controller: ScreenController<void>;
671}): JSX.Element {
672 const state = useSyncExternalStore(store.subscribe, store.snapshot);
673 const [selection, setSelection] = useState<ReadonlyArray<string>>([]);
674 const [columnIndex, setColumnIndex] = useState(0);
675 const [query, setQuery] = useState("");
676 const [searching, setSearching] = useState(false);
677 const [previewOffset, setPreviewOffset] = useState(0);
678 const [previewFocused, setPreviewFocused] = useState(false);
679 const [marked, setMarked] = useState<ReadonlyMap<string, StateBrowserNode>>(
680 new Map(),
681 );
682 const [deletion, setDeletion] = useState<
683 | {
684 readonly status: "confirm" | "deleting";
685 readonly targets: ReadonlyArray<StateBrowserNode>;
686 }
687 | {
688 readonly status: "error";
689 readonly message: string;
690 readonly targets: ReadonlyArray<StateBrowserNode>;
691 }
692 >();
693 const [notice, setNotice] = useState<string>();
694 const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
695 const borderStyle = useBorderStyle();
696 const keys = useKeyGlyphs();
697
698 useEffect(() => {
699 store.loadRoot();
700 return store.dispose;
701 }, [store]);
702 const columns = useMemo(
703 () => buildColumns(state, selection),
704 [selection, state],
705 );
706 const markedIds = useMemo(() => new Set(marked.keys()), [marked]);
707 const activeColumn = Math.min(columnIndex, columns.length - 1);
708 const column = columns[activeColumn]!;
709 const filteredItems =
710 column.state.status !== "ready"
711 ? []
712 : query.trim() === ""
713 ? column.state.value
714 : column.state.value.filter((node) =>
715 node.path.toLowerCase().includes(query.trim().toLowerCase()),
716 );
717 const selectedId = selection[activeColumn];
718 const selectedIndex = filteredItems.findIndex(
719 (node) => node.id === selectedId,
720 );
721 const selected = filteredItems[selectedIndex];
722 const lastSelected = (() => {
723 for (let index = columns.length - 1; index >= 0; index--) {
724 const candidate = columns[index];
725 if (candidate?.state.status !== "ready") continue;
726 const node = candidate.state.value.find(
727 (item) => item.id === selection[index],
728 );
729 if (node !== undefined) return node;
730 }
731 return undefined;
732 })();
733 const file =
734 lastSelected?.kind === "resource" || lastSelected?.kind === "output"
735 ? lastSelected
736 : undefined;
737 const fileState = file === undefined ? undefined : state.files.get(file.id);
738 // The delete prompt replaces the key bar (a margin row + the keys) with a
739 // bordered panel: border, header line, one line per target (capped, with
740 // a "+N more" line), and its own key bar. KeyBar is a full-width block,
741 // so it gets its own row rather than sharing one with the header.
742 const promptTargets =
743 deletion === undefined ? [] : deletion.targets.slice(0, MAX_PROMPT_TARGETS);
744 const promptOverflow =
745 deletion === undefined ? 0 : deletion.targets.length - promptTargets.length;
746 const footerHeight =
747 deletion === undefined
748 ? 2
749 : 3 + promptTargets.length + (promptOverflow > 0 ? 1 : 0);
750 const contentHeight = Math.max(6, terminalRows - 8 - footerHeight);
751 const previewLines = useMemo(
752 () =>
753 fileState?.status === "ready" && fileState.value !== undefined
754 ? formatYamlLines(fileState.value)
755 : undefined,
756 [fileState],
757 );
758 const maxPreviewOffset =
759 previewLines === undefined
760 ? 0
761 : Math.max(0, previewLines.length - Math.max(1, contentHeight - 2));
762
763 const choose = (index: number, node: StateBrowserNode) => {
764 setSelection((current) => [...current.slice(0, index), node.id]);
765 setPreviewOffset(0);
766 if (node.kind === "resource" || node.kind === "output")
767 store.loadFile(node);
768 else store.loadChildren(node);
769 };
770 const move = (delta: number) => {
771 if (filteredItems.length === 0) return;
772 const next =
773 selectedIndex < 0
774 ? delta < 0
775 ? filteredItems.length - 1
776 : 0
777 : Math.max(
778 0,
779 Math.min(filteredItems.length - 1, selectedIndex + delta),
780 );
781 choose(activeColumn, filteredItems[next]!);
782 };
783
784 const beginDelete = () => {
785 const targets =
786 marked.size > 0 ? [...marked.values()] : selected ? [selected] : [];
787 if (targets.length === 0) return;
788 if (targets.some((node) => node.kind === "output")) {
789 setDeletion({
790 status: "error",
791 message: "output cannot be deleted independently",
792 targets,
793 });
794 return;
795 }
796 setDeletion({ status: "confirm", targets });
797 };
798
799 const confirmDelete = () => {
800 if (deletion === undefined || deletion.status === "deleting") return;
801 const targets = deletion.targets;
802 const deletes = (node: StateBrowserNode) =>
803 targets.some(
804 (target) =>
805 node.id === target.id || node.path.startsWith(`${target.path}/`),
806 );
807 // The shallowest column whose selected node is going away: the cursor
808 // moves there, onto the next surviving sibling (or the previous one).
809 // Deeper selection is dropped; everything above it is untouched.
810 const landing = (() => {
811 for (let index = 0; index < columns.length; index++) {
812 const column = columns[index]!;
813 if (column.state.status !== "ready") return undefined;
814 const items = column.state.value;
815 const position = items.findIndex(
816 (node) => node.id === selection[index],
817 );
818 if (position < 0 || !deletes(items[position]!)) continue;
819 const fallback =
820 items.slice(position + 1).find((node) => !deletes(node)) ??
821 items.slice(0, position).findLast((node) => !deletes(node));
822 return { index, fallback };
823 }
824 return undefined;
825 })();
826 setDeletion({ status: "deleting", targets });
827 store.deleteNodes(targets, {
828 onSuccess: () => {
829 setDeletion(undefined);
830 setMarked(new Map());
831 if (landing !== undefined) {
832 setSelection((current) =>
833 landing.fallback === undefined
834 ? current.slice(0, landing.index)
835 : [...current.slice(0, landing.index), landing.fallback.id],
836 );
837 setColumnIndex(landing.index);
838 setPreviewOffset(0);
839 setPreviewFocused(false);
840 }
841 setNotice(
842 `Deleted state at ${targets.map((target) => `${target.path}${target.kind === "resource" ? "" : "/"}`).join(", ")}`,
843 );
844 store.invalidate(targets);
845 },
846 onFailure: (message) =>
847 setDeletion({ status: "error", message, targets }),
848 });
849 };
850
851 useTerminalInput(
852 (input, key) => {
853 if (deletion !== undefined) {
854 if (deletion.status === "deleting") return;
855 if (key.escape || input === "n" || input === "q")
856 setDeletion(undefined);
857 else if (deletion.status === "confirm" && input === "y")
858 confirmDelete();
859 return;
860 }
861 if (input === "q") return controller.submit(undefined);
862 if (key.escape) {
863 if (query !== "") setQuery("");
864 else controller.submit(undefined);
865 return;
866 }
867 if (!key.ctrl && !key.meta && input === "/") {
868 setSearching(true);
869 setPreviewFocused(false);
870 return;
871 }
872 if (!key.ctrl && !key.meta && input === "r") {
873 setSelection([]);
874 setColumnIndex(0);
875 setPreviewFocused(false);
876 setNotice(undefined);
877 store.refresh();
878 return;
879 }
880 if (input === " " && selected !== undefined) {
881 setMarked((current) => {
882 const next = new Map(current);
883 if (next.has(selected.id)) next.delete(selected.id);
884 else next.set(selected.id, selected);
885 return next;
886 });
887 return;
888 }
889 if (input === "d") {
890 beginDelete();
891 return;
892 }
893 if (key.tab) {
894 if (file !== undefined) setPreviewFocused((current) => !current);
895 return;
896 }
897 if (previewFocused) {
898 if (key.left) setPreviewFocused(false);
899 else if (key.up) setPreviewOffset((value) => Math.max(0, value - 1));
900 else if (key.down)
901 setPreviewOffset((value) => Math.min(maxPreviewOffset, value + 1));
902 else if (key.pageUp)
903 setPreviewOffset((value) => Math.max(0, value - contentHeight));
904 else if (key.pageDown)
905 setPreviewOffset((value) =>
906 Math.min(maxPreviewOffset, value + contentHeight),
907 );
908 else if (key.home) setPreviewOffset(0);
909 else if (key.end) setPreviewOffset(maxPreviewOffset);
910 return;
911 }
912 if (key.up || input === "k") move(-1);
913 else if (key.down || input === "j") move(1);
914 else if (key.home && filteredItems[0])
915 choose(activeColumn, filteredItems[0]);
916 else if (key.end && filteredItems.at(-1))
917 choose(activeColumn, filteredItems.at(-1)!);
918 else if (key.left && activeColumn > 0) setColumnIndex(activeColumn - 1);
919 else if (key.right || key.enter) {
920 if (selected === undefined) return;
921 choose(activeColumn, selected);
922 if (selected.kind === "resource" || selected.kind === "output") {
923 setPreviewFocused(true);
924 } else {
925 setColumnIndex(activeColumn + 1);
926 }
927 }
928 },
929 { active: !searching },
930 );
931
932 const previewWidth =
933 file === undefined ? 0 : Math.max(34, Math.floor(terminalColumns * 0.42));
934 const columnWidth = Math.max(
935 20,
936 Math.min(28, Math.floor((terminalColumns - previewWidth) / 2)),
937 );
938 const availableForColumns = Math.max(
939 columnWidth,
940 terminalColumns - previewWidth - 2,
941 );
942 const visibleColumnCount = Math.max(
943 1,
944 Math.floor(availableForColumns / (columnWidth + 1)),
945 );
946 const visibleStart = Math.max(0, columns.length - visibleColumnCount);
947 const visibleColumns = columns.slice(visibleStart);
948 const currentPath = selected?.path ?? "/";
949 const displayPath =
950 selected === undefined ||
951 selected.kind === "resource" ||
952 selected.kind === "output"
953 ? currentPath
954 : `${currentPath}/`;
955
956 return (
957 // Pinned to the terminal width: without it the root sizes to its content,
958 // and the layout visibly narrows whenever the full-width key bar is
959 // swapped out for the delete panel.
960 <Box
961 flexDirection="column"
962 width={terminalColumns}
963 height={Math.max(12, terminalRows - 2)}
964 >
965 <Box
966 justifyContent="space-between"
967 flexShrink={0}
968 paddingX={1}
969 marginBottom={1}
970 borderStyle={borderStyle}
971 borderBottom
972 borderTop={false}
973 borderLeft={false}
974 borderRight={false}
975 borderColor={theme.color.muted}
976 borderDimColor
977 >
978 <Box gap={1}>
979 <Text color={theme.color.brand}>●</Text>
980 <Text bold>state</Text>
981 <Text color={theme.color.accent} wrap="truncate-middle">
982 · {displayPath}
983 </Text>
984 </Box>
985 <Text bold color={theme.color.brand}>
986 {store.source.backend}
987 </Text>
988 </Box>
989 {notice === undefined ? null : (
990 <Box paddingX={1} flexShrink={0}>
991 <Text color={theme.color.success}>✓ {notice}</Text>
992 </Box>
993 )}
994 {searching ? (
995 <Box gap={1} paddingX={1} flexShrink={0}>
996 <Text color={theme.color.brand}>/</Text>
997 <TextField
998 value={query}
999 placeholder="filter this column"
1000 active
1001 onChange={setQuery}
1002 onSubmit={() => setSearching(false)}
1003 onCancel={() => setSearching(false)}
1004 />
1005 </Box>
1006 ) : query === "" ? null : (
1007 <Box paddingX={1}>
1008 <Text tone="muted">filter · {query}</Text>
1009 </Box>
1010 )}
1011 <Box flexGrow={1}>
1012 {visibleColumns.map((item, offset) => {
1013 const index = visibleStart + offset;
1014 return (
1015 <Box
1016 key={item.id}
1017 width={columnWidth}
1018 flexDirection="column"
1019 paddingX={1}
1020 borderStyle={borderStyle}
1021 borderLeft={offset > 0}
1022 borderRight={false}
1023 borderTop={false}
1024 borderBottom={false}
1025 borderColor={
1026 !previewFocused && index === activeColumn
1027 ? theme.color.accent
1028 : theme.color.muted
1029 }
1030 >
1031 <Text
1032 bold
1033 color={
1034 !previewFocused && index === activeColumn
1035 ? theme.color.accent
1036 : theme.color.muted
1037 }
1038 >
1039 {item.title.toUpperCase()}
1040 </Text>
1041 <Box height={contentHeight} flexDirection="column">
1042 <Column
1043 column={item}
1044 selected={selection[index]}
1045 focused={!previewFocused && index === activeColumn}
1046 height={contentHeight}
1047 query={index === activeColumn ? query : ""}
1048 marked={markedIds}
1049 />
1050 </Box>
1051 </Box>
1052 );
1053 })}
1054 {file === undefined ? null : (
1055 <Box
1056 flexGrow={1}
1057 flexDirection="column"
1058 paddingLeft={1}
1059 borderStyle={borderStyle}
1060 borderLeft
1061 borderRight={false}
1062 borderTop={false}
1063 borderBottom={false}
1064 borderColor={
1065 previewFocused ? theme.color.accent : theme.color.muted
1066 }
1067 >
1068 <Text
1069 bold
1070 color={previewFocused ? theme.color.accent : theme.color.muted}
1071 >
1072 PREVIEW
1073 </Text>
1074 <Preview
1075 node={file}
1076 state={fileState}
1077 lines={previewLines}
1078 offset={previewOffset}
1079 height={contentHeight}
1080 />
1081 </Box>
1082 )}
1083 </Box>
1084 {deletion === undefined ? (
1085 <Box paddingX={1} flexShrink={0}>
1086 <KeyBar
1087 keys={[
1088 [keys.upDown, previewFocused ? "scroll" : "select"],
1089 [keys.leftRight, "columns"],
1090 ["/", "search"],
1091 ["r", "refresh"],
1092 [keys.space, "select"],
1093 ["d", "delete"],
1094 ...(file === undefined ? [] : ([[keys.tab, "preview"]] as const)),
1095 ["q", "quit"],
1096 ]}
1097 />
1098 </Box>
1099 ) : (
1100 <Box
1101 flexDirection="column"
1102 paddingX={1}
1103 flexShrink={0}
1104 height={footerHeight}
1105 borderStyle={borderStyle}
1106 borderTop
1107 borderBottom={false}
1108 borderLeft={false}
1109 borderRight={false}
1110 borderColor={theme.color.danger}
1111 >
1112 <Box height={1}>
1113 {deletion.status === "deleting" ? (
1114 <Spinner label="Deleting state" />
1115 ) : deletion.status === "error" ? (
1116 <Text tone="danger" wrap="truncate-end">
1117 {deletion.message}
1118 </Text>
1119 ) : (
1120 <Text wrap="truncate-end">
1121 <Text bold color={theme.color.danger}>
1122 Delete state records?
1123 </Text>
1124 <Text tone="muted">
1125 {" "}
1126 · Cloud resources will not be deleted.
1127 </Text>
1128 </Text>
1129 )}
1130 </Box>
1131 <Box flexDirection="column">
1132 {promptTargets.map((target) => (
1133 <Text key={target.id} wrap="truncate-middle">
1134 {" "}
1135 {target.path}
1136 {target.kind === "resource" ? "" : "/"}
1137 </Text>
1138 ))}
1139 {promptOverflow > 0 ? (
1140 <Text tone="muted">{` +${promptOverflow} more`}</Text>
1141 ) : null}
1142 </Box>
1143 <DeletionAction status={deletion.status} />
1144 </Box>
1145 )}
1146 </Box>
1147 );
1148}
1149
1150const MAX_PROMPT_TARGETS = 4;
1151
1152type DeletionActionProps = {
1153 status: "confirm" | "deleting" | "error";
1154};
1155
1156function DeletionAction({ status }: DeletionActionProps) {
1157 if (status === "deleting") return null;
1158 if (status === "error")
1159 return <KeyBar marginTop={0} keys={[["esc", "dismiss"]]} />;
1160 return (
1161 <KeyBar
1162 marginTop={0}
1163 keys={[
1164 ["y", "delete"],
1165 ["n/esc", "cancel"],
1166 ]}
1167 />
1168 );
1169}
1170
1171export const stateExplorerScreen = (
1172 source: StateExplorerSource,
1173 services?: Context.Context<never>,
1174) => {
1175 const store = new StateExplorerStore(source, services);
1176 return Screen.make<void>("state explorer", (controller) => (
1177 <StateExplorer store={store} controller={controller} />
1178 ));
1179};