| 1 | import { useMemo, useState } from "@alchemy.run/sigil/react"; |
| 2 | import { useTerminalInput } from "../ui/index.ts"; |
| 3 | import { stackOutputLineCount } from "./StackOutputs.tsx"; |
| 4 | import { |
| 5 | type PlanRow, |
| 6 | type PlanTree, |
| 7 | type PlanTreeState, |
| 8 | type PlanView, |
| 9 | } from "./PlanTree.ts"; |
| 10 | import { isTerminalStatus } from "./statusStyle.ts"; |
| 11 | |
| 12 | export 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 | |
| 26 | const 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 | |
| 58 | export 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 | |
| 68 | export 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 | }; |