| 1 | import { |
| 2 | measureElement, |
| 3 | useProgress, |
| 4 | useTitle, |
| 5 | type DOMElement, |
| 6 | } from "@alchemy.run/sigil"; |
| 7 | import { useLayoutEffect, useRef, useState } from "@alchemy.run/sigil/react"; |
| 8 | import { useTerminalSize } from "../ui/index.ts"; |
| 9 | import type { PlanTree, PlanTreeState } from "./PlanTree.ts"; |
| 10 | |
| 11 | export const usePlanPresentation = (options: { |
| 12 | readonly tree: PlanTree; |
| 13 | readonly state: PlanTreeState; |
| 14 | readonly busy: boolean; |
| 15 | readonly collapsible: boolean; |
| 16 | readonly hasFooter: boolean; |
| 17 | }) => { |
| 18 | const { tree, state, busy, collapsible, hasFooter } = options; |
| 19 | const { mode } = tree; |
| 20 | const { label, expanded, viewport, outcome } = state; |
| 21 | const { completed, failures, total } = tree.progress(); |
| 22 | const collapsed = collapsible && !expanded; |
| 23 | const settled = !busy && completed >= total; |
| 24 | |
| 25 | useProgress( |
| 26 | mode === "apply" |
| 27 | ? { |
| 28 | state: |
| 29 | outcome === "failure" || failures > 0 |
| 30 | ? "error" |
| 31 | : busy |
| 32 | ? "normal" |
| 33 | : "inactive", |
| 34 | value: total === 0 ? undefined : (completed / total) * 100, |
| 35 | } |
| 36 | : { state: "inactive" }, |
| 37 | ); |
| 38 | const titleProgress = settled || total === 0 ? "" : ` ${completed}/${total}`; |
| 39 | const titleDetail = tree.titleDetail ? ` · ${tree.titleDetail}` : ""; |
| 40 | useTitle( |
| 41 | mode === "apply" ? `${label}${titleProgress}${titleDetail}` : undefined, |
| 42 | ); |
| 43 | |
| 44 | const beforeRef = useRef<DOMElement>(null); |
| 45 | const summaryRef = useRef<DOMElement>(null); |
| 46 | const controlsRef = useRef<DOMElement>(null); |
| 47 | const afterRef = useRef<DOMElement>(null); |
| 48 | const [chromeRows, setChromeRows] = useState<number>(); |
| 49 | const { rows: terminalRows } = useTerminalSize(); |
| 50 | const showControls = |
| 51 | mode === "apply" || (!collapsed && (collapsible || hasFooter)); |
| 52 | |
| 53 | useLayoutEffect(() => { |
| 54 | if (collapsed || viewport !== "virtual") return; |
| 55 | const height = (ref: { readonly current: DOMElement | null }) => |
| 56 | ref.current === null ? 0 : measureElement(ref.current).height; |
| 57 | const measured = |
| 58 | height(beforeRef) + |
| 59 | height(summaryRef) + |
| 60 | height(controlsRef) + |
| 61 | height(afterRef) + |
| 62 | 1 + |
| 63 | (showControls ? 1 : 0); |
| 64 | setChromeRows((current) => (current === measured ? current : measured)); |
| 65 | }); |
| 66 | |
| 67 | const lineBudget = |
| 68 | viewport === "virtual" |
| 69 | ? Math.max( |
| 70 | 1, |
| 71 | terminalRows - (mode === "apply" ? 3 : 0) - (chromeRows ?? 8), |
| 72 | ) |
| 73 | : Number.POSITIVE_INFINITY; |
| 74 | |
| 75 | return { |
| 76 | progress: { completed, failures, total }, |
| 77 | collapsed, |
| 78 | showControls, |
| 79 | lineBudget, |
| 80 | refs: { beforeRef, summaryRef, controlsRef, afterRef }, |
| 81 | } as const; |
| 82 | }; |