1import { useMemo, useState } from "@alchemy.run/sigil/react";
2import { useTerminalInput } from "../ui/index.ts";
3import { stackOutputLineCount } from "./StackOutputs.tsx";
4import {
5 type PlanRow,
6 type PlanTree,
7 type PlanTreeState,
8 type PlanView,
9} from "./PlanTree.ts";
10import { isTerminalStatus } from "./statusStyle.ts";
11
12export type VirtualPlanLine =
13 | { readonly kind: "row"; readonly row: PlanRow }
14 | {
15 readonly kind: "yaml";
16 readonly key: string;
17 readonly line: string;
18 readonly paddingLeft: number;
19 }
20 | {
21 readonly kind: "note";
22 readonly key: string;
23 readonly paddingLeft: number;
24 };
25
26const planLines = (
27 rows: readonly PlanRow[],
28 detailed: boolean,
29): VirtualPlanLine[] =>
30 rows.flatMap((row): VirtualPlanLine[] => {
31 const lines: VirtualPlanLine[] = [{ kind: "row", row }];
32 if (row.type !== "resource") return lines;
33 const showYaml = detailed || row.propertyYaml?.kind === "drift";
34 if (!showYaml) return lines;
35 if (row.propertyYaml !== undefined) {
36 lines.push(
37 ...row.propertyYaml.lines.map((line, index) => ({
38 kind: "yaml" as const,
39 key: `${row.key}:yaml:${index}`,
40 line,
41 paddingLeft: row.depth * 2 + 2,
42 })),
43 );
44 } else if (
45 row.action === "update" ||
46 row.action === "adopted" ||
47 row.action === "replace"
48 ) {
49 lines.push({
50 kind: "note",
51 key: `${row.key}:note`,
52 paddingLeft: row.depth * 2 + 2,
53 });
54 }
55 return lines;
56 });
57
58export interface PlanWindow {
59 readonly selectedView: PlanView;
60 readonly hasOutput: boolean;
61 readonly virtual: boolean;
62 readonly budget: number;
63 readonly offset: number;
64 readonly hiddenBelow: number;
65 readonly planLines: readonly VirtualPlanLine[] | undefined;
66}
67
68export const usePlanViewport = (options: {
69 readonly tree: PlanTree;
70 readonly state: PlanTreeState;
71 readonly lineBudget: number;
72 readonly collapsible: boolean;
73 readonly collapsed: boolean;
74}): PlanWindow => {
75 const { tree, state, lineBudget, collapsible, collapsed } = options;
76 const { rows, detailed } = tree;
77 const { tasks, output, view, viewport } = state;
78 const hasOutput = output !== undefined;
79 const selectedView = view === "output" && hasOutput ? "output" : "plan";
80 const virtual = viewport === "virtual";
81 const expandedLines = useMemo(
82 () => planLines(rows, detailed),
83 [rows, detailed],
84 );
85 const available =
86 lineBudget === Number.POSITIVE_INFINITY
87 ? rows.length
88 : Math.max(1, Math.floor(lineBudget));
89 const length =
90 selectedView === "output"
91 ? stackOutputLineCount(output)
92 : virtual
93 ? expandedLines.length
94 : rows.length;
95 // Overflow markers render above and below the content window, so they must
96 // consume its budget rather than extending the complete widget. Reserve
97 // both even when following the end (where only the upper marker is shown),
98 // because manual scrolling can make both visible without changing height.
99 const budget =
100 virtual && length > available ? Math.max(1, available - 2) : available;
101 const maxOffset = Math.max(0, length - budget);
102 const activeRow = tree.progressRows.find((row) => {
103 const status = tasks.get(row.key)?.status;
104 return status !== undefined && !isTerminalStatus(status);
105 });
106 const activeRowIndex = activeRow === undefined ? -1 : rows.indexOf(activeRow);
107 const activeLineIndex = virtual
108 ? expandedLines.findIndex(
109 (line) => line.kind === "row" && line.row === rows[activeRowIndex],
110 )
111 : activeRowIndex;
112 const followedOffset =
113 selectedView === "output"
114 ? maxOffset
115 : Math.min(
116 maxOffset,
117 Math.max(
118 0,
119 (activeLineIndex < 0 ? length : activeLineIndex) -
120 Math.floor(budget / 3),
121 ),
122 );
123 const [manualOffsets, setManualOffsets] = useState<
124 Record<PlanView, number | undefined>
125 >({ plan: undefined, output: undefined });
126 const offset = virtual
127 ? Math.min(maxOffset, manualOffsets[selectedView] ?? followedOffset)
128 : 0;
129 const setOffset = (
130 update: (current: number | undefined) => number | undefined,
131 ) =>
132 setManualOffsets((current) => ({
133 ...current,
134 [selectedView]: update(current[selectedView]),
135 }));
136
137 useTerminalInput((input, key) => {
138 if (collapsible && !key.ctrl && !key.meta && input.toLowerCase() === "p") {
139 tree.setExpanded(collapsed);
140 return;
141 }
142 if (collapsed) return;
143 if (hasOutput && (key.left || key.right)) {
144 tree.setView(selectedView === "plan" ? "output" : "plan");
145 return;
146 }
147 if (!virtual) return;
148 const page = Math.max(1, Math.floor(lineBudget));
149 if (key.up) setOffset((current) => Math.max(0, (current ?? offset) - 1));
150 else if (key.down)
151 setOffset((current) => Math.min(maxOffset, (current ?? offset) + 1));
152 else if (key.pageUp)
153 setOffset((current) => Math.max(0, (current ?? offset) - page));
154 else if (key.pageDown)
155 setOffset((current) => Math.min(maxOffset, (current ?? offset) + page));
156 else if (key.home) setOffset(() => 0);
157 else if (key.end) setOffset(() => undefined);
158 });
159
160 return {
161 selectedView,
162 hasOutput,
163 virtual,
164 budget,
165 offset,
166 hiddenBelow: virtual ? Math.max(0, length - offset - budget) : 0,
167 planLines:
168 virtual && selectedView === "plan"
169 ? expandedLines.slice(offset, offset + budget)
170 : undefined,
171 };
172};