spinuptui 1.3.0-alpha.2 → 1.3.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ // The station heartbeat overlay (Modules tab, `h` on Scheduler): the same flow
2
+ // as `spinuptui scheduler heartbeat setup`, with the confirm-before-firing
3
+ // pattern. With a saved Kuma login it finds or creates this station's push
4
+ // monitor; without one it takes a push URL made by hand. Once set, it shows the
5
+ // last ping and can send a test or remove it.
6
+
7
+ import { useEffect, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme } from "../../lib/theme.ts"
10
+ import { truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { loadConfig } from "../../config.ts"
15
+ import { readLastTick } from "../../lib/schedulerJobs.ts"
16
+ import { stationName } from "../../lib/schedulerGit.ts"
17
+ import {
18
+ applyStationPlan,
19
+ connectHeartbeat,
20
+ loadStationPlan,
21
+ pagerProblem,
22
+ removeHeartbeat,
23
+ sendHeartbeat,
24
+ stationPlanText,
25
+ tickHeartbeat,
26
+ type StationMonitorPlan,
27
+ } from "../../lib/heartbeat.ts"
28
+
29
+ type Phase = "status" | "loading" | "choose" | "confirm" | "manual" | "working" | "done" | "error" | "confirmRemove"
30
+
31
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
32
+ const WIDTH = 76
33
+
34
+ export function HeartbeatSetup() {
35
+ const { setHeartbeatSetupOpen, setInputMode } = useStore()
36
+ const host = stationName()
37
+ const [current, setCurrent] = useState(() => loadConfig().scheduler?.heartbeat ?? null)
38
+ const hasKuma = !!loadConfig().uptimeKuma
39
+ const [phase, setPhase] = useState<Phase>(current ? "status" : hasKuma ? "loading" : "manual")
40
+ const [plan, setPlan] = useState<StationMonitorPlan | null>(null)
41
+ const [pick, setPick] = useState(0)
42
+ const [url, setUrl] = useState("")
43
+ const [message, setMessage] = useState<{ text: string; remedy?: string; good?: boolean } | null>(null)
44
+
45
+ const close = () => setHeartbeatSetupOpen(false)
46
+
47
+ const startPlan = () => {
48
+ setPhase("loading")
49
+ void loadStationPlan(host).then((r) => {
50
+ if (!r.ok) {
51
+ setMessage({ text: r.message, remedy: r.remedy })
52
+ return setPhase("error")
53
+ }
54
+ setPlan(r.plan)
55
+ setPick(0)
56
+ setPhase(!r.plan.existing && r.plan.needChoice ? "choose" : "confirm")
57
+ })
58
+ }
59
+
60
+ useEffect(() => {
61
+ if (phase === "loading" && !plan) startPlan()
62
+ // Only the first load; later loads go through startPlan directly.
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [])
65
+
66
+ useEffect(() => {
67
+ if (phase !== "manual") return
68
+ setInputMode(true)
69
+ return () => setInputMode(false)
70
+ }, [phase, setInputMode])
71
+
72
+ const finish = (text: string, status: "up" | "down") => {
73
+ setCurrent(loadConfig().scheduler?.heartbeat ?? null)
74
+ const why = status === "down" ? ` It says "down" because ${pagerProblem()}; the next tick says "up" once that's fixed.` : ""
75
+ const tail = loadConfig().scheduler?.dispatcher ? " Every dispatcher tick pings it from now on." : " This machine has no dispatcher yet: install Scheduler (⏎ on its card) so ticks ping it."
76
+ setMessage({ text: text + why + tail, good: true })
77
+ setPhase("done")
78
+ }
79
+
80
+ const apply = () => {
81
+ if (!plan) return
82
+ setPhase("working")
83
+ void applyStationPlan(plan, host).then((r) => {
84
+ if (!r.ok) {
85
+ setMessage({ text: r.message, remedy: r.remedy })
86
+ return setPhase("error")
87
+ }
88
+ finish(`${r.created ? `Created "${plan.name}" (#${r.created})` : `Reused "${plan.name}"`} and sent the first ping (status ${r.status}).`, r.status)
89
+ })
90
+ }
91
+
92
+ const submitUrl = () => {
93
+ if (!url.trim()) return
94
+ setPhase("working")
95
+ void connectHeartbeat(url.trim(), host).then((r) => {
96
+ if (!r.ok) {
97
+ setMessage({ text: r.message, remedy: r.remedy })
98
+ return setPhase("error")
99
+ }
100
+ finish(`Saved. Kuma took the ping (status ${r.status}).`, r.status)
101
+ })
102
+ }
103
+
104
+ const test = () => {
105
+ if (!current) return
106
+ setPhase("working")
107
+ const t = tickHeartbeat(0, [], pagerProblem())
108
+ void sendHeartbeat(current.url, t.status, `${host}: test ping (${t.msg})`).then((r) => {
109
+ setMessage(r.ok ? { text: `Kuma took the test ping (status ${t.status}).`, good: true } : { text: `Kuma didn't take the ping: ${r.error}`, remedy: "Check that the monitor is active and that this machine can reach Kuma." })
110
+ setPhase(r.ok ? "done" : "error")
111
+ })
112
+ }
113
+
114
+ useKeyboard((key) => {
115
+ const name = key.name ?? ""
116
+ if (phase === "manual") {
117
+ if (name === "escape") close()
118
+ return
119
+ }
120
+ if (phase === "working" || phase === "loading") return
121
+ if (name === "escape" || name === "q") return phase === "confirmRemove" ? setPhase("status") : close()
122
+ if (phase === "status") {
123
+ if (name === "t") return test()
124
+ if (name === "s" && hasKuma) return startPlan()
125
+ if (name === "x") return setPhase("confirmRemove")
126
+ }
127
+ if (phase === "confirmRemove" && name === "y") {
128
+ void removeHeartbeat().then(() => {
129
+ setCurrent(null)
130
+ setMessage({ text: "Removed. Pause or delete the monitor in Kuma too, or it will report this station as down.", good: true })
131
+ setPhase("done")
132
+ })
133
+ return
134
+ }
135
+ if (phase === "choose" && plan) {
136
+ if (name === "up" || name === "k") return setPick((i) => Math.max(0, i - 1))
137
+ if (name === "down" || name === "j") return setPick((i) => Math.min(plan.available.length - 1, i + 1))
138
+ if (name === "return") {
139
+ setPlan({ ...plan, chosen: [plan.available[pick]!], needChoice: null })
140
+ return setPhase("confirm")
141
+ }
142
+ }
143
+ if (phase === "confirm" && name === "y") return apply()
144
+ if (phase === "error" || phase === "done") {
145
+ if (name === "u") {
146
+ setUrl("")
147
+ return setPhase("manual")
148
+ }
149
+ if (name === "r" && hasKuma) return startPlan()
150
+ }
151
+ })
152
+
153
+ return (
154
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
155
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
156
+ <text content="♥ Station heartbeat " fg={theme.brand} style={{ flexShrink: 0 }} />
157
+ <text content="know when this machine stops, from somewhere else" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
158
+ </box>
159
+ <Centered>{body()}</Centered>
160
+ <StatusBar hints={hints()} showGlobal={false} />
161
+ </box>
162
+ )
163
+
164
+ function intro() {
165
+ return (
166
+ <>
167
+ <text content="If this machine is off, its spinuptui is broken, or Scheduler's LaunchAgent is gone, nothing here can page you. So every Scheduler tick pings an Uptime Kuma push monitor running on another machine, and when the pings stop, Kuma alerts you itself." fg={theme.textDim} />
168
+ <box style={{ height: 1 }} />
169
+ </>
170
+ )
171
+ }
172
+
173
+ function body() {
174
+ if (phase === "loading" || phase === "working") {
175
+ return (
176
+ <box style={{ flexDirection: "row" }}>
177
+ <Spinner />
178
+ <text content={phase === "loading" ? " Asking Uptime Kuma…" : " Talking to Uptime Kuma…"} fg={theme.textDim} wrapMode="none" />
179
+ </box>
180
+ )
181
+ }
182
+ if (phase === "status" && current) {
183
+ const last = readLastTick()?.heartbeat ?? null
184
+ const pager = pagerProblem()
185
+ return (
186
+ <Panel title=" Station heartbeat " active>
187
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
188
+ <text content={`✓ Pings ${new URL(current.url).host} at the end of every Scheduler tick.`} fg={theme.good} wrapMode="none" />
189
+ <text content={`Set ${current.setAt.slice(0, 10)}.`} fg={theme.textDim} />
190
+ <box style={{ height: 1 }} />
191
+ <text
192
+ content={last ? `Last ping: ${last.ok ? "accepted" : `failed (${truncate(last.error ?? "", 50)})`}, status ${last.status}.` : "No Scheduler tick has pinged it yet."}
193
+ fg={last && !last.ok ? theme.bad : theme.text}
194
+ />
195
+ {pager && <text content={`Pings say "down" right now: ${pager}.`} fg={theme.warn} />}
196
+ </box>
197
+ </Panel>
198
+ )
199
+ }
200
+ if (phase === "choose" && plan) {
201
+ return (
202
+ <Panel title=" Who should Kuma alert? " active>
203
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
204
+ {intro()}
205
+ <text content="This Kuma has several notifications and none is the default. Pick the one that should wake you when this station goes quiet:" fg={theme.text} />
206
+ <box style={{ height: 1 }} />
207
+ {plan.available.map((n, i) => (
208
+ <text key={n.id} content={`${i === pick ? "▸ " : " "}${n.name}`} fg={i === pick ? theme.accent : theme.text} wrapMode="none" />
209
+ ))}
210
+ </box>
211
+ </Panel>
212
+ )
213
+ }
214
+ if (phase === "confirm" && plan) {
215
+ const { steps, notes } = stationPlanText(plan)
216
+ return (
217
+ <Panel title=" The plan " active>
218
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
219
+ {intro()}
220
+ {steps.map((s, i) => (
221
+ <text key={s} content={`${i + 1}. ${s}`} fg={theme.text} />
222
+ ))}
223
+ <box style={{ height: 1 }} />
224
+ {notes.map((n) => (
225
+ <text key={n} content={n} fg={theme.textFaint} />
226
+ ))}
227
+ <box style={{ height: 1 }} />
228
+ <text content="Press y to do it · Esc to cancel" fg={theme.text} wrapMode="none" />
229
+ </box>
230
+ </Panel>
231
+ )
232
+ }
233
+ if (phase === "manual") {
234
+ return (
235
+ <Panel title=" Connect a Kuma push monitor " active>
236
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
237
+ {intro()}
238
+ <text content="SpinupTUI has no Kuma login to create the monitor with (connect one with M on any site). Or make it yourself: in Kuma, Add New Monitor → Monitor Type: Push, Heartbeat Interval 900 seconds, with a notification. Then paste its push URL:" fg={theme.text} />
239
+ <box style={{ height: 1 }} />
240
+ <input focused value={url} placeholder="https://kuma.example.com/api/push/…" onInput={(v: string) => setUrl(v)} onSubmit={submitUrl} style={INPUT_STYLE} />
241
+ <box style={{ height: 1 }} />
242
+ <text content="SpinupTUI pings it once and saves it only if Kuma accepts the ping." fg={theme.textFaint} />
243
+ </box>
244
+ </Panel>
245
+ )
246
+ }
247
+ if (phase === "confirmRemove") {
248
+ return (
249
+ <Panel title=" Remove the heartbeat? " active>
250
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
251
+ <text content="Scheduler stops pinging Kuma. The monitor stays in Kuma and will report this station as down, so pause or delete it there too." fg={theme.text} />
252
+ <box style={{ height: 1 }} />
253
+ <text content="Press y to remove · Esc to keep it" fg={theme.text} wrapMode="none" />
254
+ </box>
255
+ </Panel>
256
+ )
257
+ }
258
+ const good = phase === "done" && message?.good
259
+ return (
260
+ <Panel title={good ? " Done " : " Didn't work "} active>
261
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
262
+ <text content={`${good ? "✓" : "✕"} ${message?.text ?? ""}`} fg={good ? theme.good : theme.bad} />
263
+ {message?.remedy && (
264
+ <>
265
+ <box style={{ height: 1 }} />
266
+ <text content={message.remedy} fg={theme.textDim} />
267
+ </>
268
+ )}
269
+ </box>
270
+ </Panel>
271
+ )
272
+ }
273
+
274
+ function hints() {
275
+ const esc = { key: "esc", label: "close" }
276
+ if (phase === "status") return [{ key: "t", label: "test ping" }, ...(hasKuma ? [{ key: "s", label: "redo setup" }] : []), { key: "x", label: "remove" }, esc]
277
+ if (phase === "choose") return [{ key: "↑↓", label: "choose" }, { key: "⏎", label: "use it" }, esc]
278
+ if (phase === "confirm") return [{ key: "y", label: "do it" }, { key: "esc", label: "cancel" }]
279
+ if (phase === "manual") return [{ key: "⏎", label: "ping & save" }, { key: "esc", label: "cancel" }]
280
+ if (phase === "confirmRemove") return [{ key: "y", label: "remove" }, { key: "esc", label: "keep" }]
281
+ if (phase === "error") return [...(hasKuma ? [{ key: "r", label: "try again" }] : []), { key: "u", label: "paste a URL instead" }, esc]
282
+ return [esc]
283
+ }
284
+ }
@@ -0,0 +1,299 @@
1
+ // The SpinupTUI folder overlay (Modules tab, `f`): where your jobs and reports
2
+ // live, and sharing that folder across machines — `spinuptui home sync` with
3
+ // the confirm-before-firing pattern. Pick how to share it, give a name or URL,
4
+ // see the plan, press y. Nothing runs git until then.
5
+
6
+ import { useEffect, useState } from "react"
7
+ import { useKeyboard } from "@opentui/react"
8
+ import { theme } from "../../lib/theme.ts"
9
+ import { truncate } from "../../lib/format.ts"
10
+ import { openUrl } from "../../lib/open.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { applySync, describeShare, ensureHome, ghReady, homeStatus, planSync, tilde, type ApplyResult, type HomeStatus, type SyncOptions, type SyncPlan } from "../../lib/home.ts"
15
+
16
+ type Phase = "loading" | "pick" | "input" | "planning" | "confirm" | "blocked" | "applying" | "done" | "error"
17
+
18
+ interface Choice {
19
+ key: keyof SyncOptions
20
+ label: string
21
+ detail: string
22
+ prompt: string
23
+ placeholder: string
24
+ initial: string
25
+ }
26
+
27
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
28
+
29
+ export function HomeSync() {
30
+ const { setHomeSyncOpen, setInputMode } = useStore()
31
+ const [phase, setPhase] = useState<Phase>("loading")
32
+ const [status, setStatus] = useState<HomeStatus | null>(null)
33
+ const [gh, setGh] = useState<string | null>(null) // why gh can't be used; null = it can
34
+ const [selected, setSelected] = useState(0)
35
+ const [value, setValue] = useState("")
36
+ const [plan, setPlan] = useState<SyncPlan | null>(null)
37
+ const [result, setResult] = useState<ApplyResult | null>(null)
38
+
39
+ const choices: Choice[] = [
40
+ {
41
+ key: "github",
42
+ label: "Create a private GitHub repository for it",
43
+ detail: gh ? `${gh} Or make one yourself and use the next option.` : "Uses the GitHub CLI you're signed in to.",
44
+ prompt: "Repository name (or owner/name)",
45
+ placeholder: "spinuptui-home",
46
+ initial: "spinuptui-home",
47
+ },
48
+ {
49
+ key: "remote",
50
+ label: "Use an empty private repository I've made",
51
+ detail: "Any git host: GitHub, GitLab, Gitea, your own server.",
52
+ prompt: "Repository URL",
53
+ placeholder: "git@github.com:you/spinuptui-home.git",
54
+ initial: "",
55
+ },
56
+ {
57
+ key: "clone",
58
+ label: "Set this machine up from another machine's folder",
59
+ detail: "For a second machine: clones the repository you shared from the first.",
60
+ prompt: "Repository URL",
61
+ placeholder: "git@github.com:you/spinuptui-home.git",
62
+ initial: "",
63
+ },
64
+ ]
65
+ const choice = choices[selected]!
66
+
67
+ const close = () => setHomeSyncOpen(false)
68
+
69
+ const makePlan = (opts: SyncOptions) => {
70
+ setPhase("planning")
71
+ void planSync(opts).then((p) => {
72
+ setPlan(p)
73
+ setPhase(p.blockers.length ? "blocked" : "confirm")
74
+ })
75
+ }
76
+
77
+ useEffect(() => {
78
+ void Promise.all([homeStatus(), ghReady()]).then(([s, why]) => {
79
+ setStatus(s)
80
+ setGh(why)
81
+ // No usable gh: start on the option that works without it.
82
+ if (why) setSelected(1)
83
+ // Already shared: the plan is "push what's pending", or nothing.
84
+ if (s.git && s.remote) makePlan({})
85
+ else setPhase("pick")
86
+ })
87
+ // Once, on open.
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [])
90
+
91
+ // The input owns the keyboard while it's showing.
92
+ useEffect(() => {
93
+ if (phase !== "input") return
94
+ setInputMode(true)
95
+ return () => setInputMode(false)
96
+ }, [phase, setInputMode])
97
+
98
+ const apply = () => {
99
+ if (!plan) return
100
+ setPhase("applying")
101
+ void applySync(plan).then(async (r) => {
102
+ if (r.ok) ensureHome()
103
+ setResult(r)
104
+ setStatus(await homeStatus())
105
+ setPhase(r.ok ? "done" : "error")
106
+ })
107
+ }
108
+
109
+ useKeyboard((key) => {
110
+ const name = key.name ?? ""
111
+ if (phase === "input") {
112
+ if (name === "escape") setPhase("pick")
113
+ return
114
+ }
115
+ if (name === "escape" || name === "q") return close()
116
+ if (name === "o" && status?.exists) return openUrl(status.path)
117
+ if (phase === "pick") {
118
+ if (name === "up" || name === "k") return setSelected((i) => Math.max(0, i - 1))
119
+ if (name === "down" || name === "j") return setSelected((i) => Math.min(choices.length - 1, i + 1))
120
+ if (name === "return") {
121
+ if (choice.key === "github" && gh) return
122
+ setValue(choice.initial)
123
+ return setPhase("input")
124
+ }
125
+ }
126
+ if (phase === "confirm" && name === "y" && plan && plan.steps.length) return apply()
127
+ if ((phase === "blocked" || phase === "error") && name === "r" && !(status?.git && status.remote)) return setPhase("pick")
128
+ })
129
+
130
+ return (
131
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
132
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
133
+ <text content="▤ Your SpinupTUI folder " fg={theme.brand} style={{ flexShrink: 0 }} />
134
+ <text content={status ? tilde(status.path) : ""} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
135
+ </box>
136
+ <Centered>{body()}</Centered>
137
+ <StatusBar hints={hints()} showGlobal={false} />
138
+ </box>
139
+ )
140
+
141
+ function header() {
142
+ if (!status) return null
143
+ return (
144
+ <>
145
+ <text content="Your Scheduler jobs and reports live here, and later other modules' work too." fg={theme.text} />
146
+ <text
147
+ content={`${status.exists ? "" : "Created the first time a module writes to it. "}Its README.md explains each part.`}
148
+ fg={theme.textDim}
149
+ />
150
+ <box style={{ height: 1 }} />
151
+ <box style={{ flexDirection: "row" }}>
152
+ <text content="Now: " fg={theme.textDim} style={{ flexShrink: 0 }} />
153
+ <text content={truncate(describeShare(status), 66)} fg={status.git && status.remote ? theme.good : theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
154
+ </box>
155
+ {status.station && (
156
+ <text content={`Its jobs run on ${status.station === status.thisMachine ? "this machine" : status.station}.`} fg={theme.textDim} wrapMode="none" />
157
+ )}
158
+ <box style={{ height: 1 }} />
159
+ </>
160
+ )
161
+ }
162
+
163
+ function body() {
164
+ if (phase === "loading" || phase === "planning" || phase === "applying") {
165
+ return (
166
+ <box style={{ flexDirection: "row" }}>
167
+ <Spinner />
168
+ <text content={phase === "applying" ? " Sharing the folder…" : phase === "planning" ? " Checking the repository…" : " Looking at the folder…"} fg={theme.textDim} wrapMode="none" />
169
+ </box>
170
+ )
171
+ }
172
+ if (phase === "pick") {
173
+ return (
174
+ <Panel title=" Share it across machines " active>
175
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
176
+ {header()}
177
+ <text content="Shared through a private git repository, you can queue jobs from any machine" fg={theme.text} />
178
+ <text content="and read the reports anywhere. Scheduler pulls it before each run and pushes" fg={theme.text} />
179
+ <text content="the reports after." fg={theme.text} />
180
+ <box style={{ height: 1 }} />
181
+ {choices.map((c, i) => {
182
+ const off = c.key === "github" && !!gh
183
+ const sel = i === selected
184
+ return (
185
+ <box key={c.key} style={{ flexDirection: "column" }}>
186
+ <text content={`${sel ? "▸ " : " "}${c.label}`} fg={off ? theme.textFaint : sel ? theme.accent : theme.text} wrapMode="none" />
187
+ {sel && <text content={` ${c.detail}`} fg={off ? theme.warn : theme.textDim} />}
188
+ </box>
189
+ )
190
+ })}
191
+ </box>
192
+ </Panel>
193
+ )
194
+ }
195
+ if (phase === "input") {
196
+ return (
197
+ <Panel title={` ${choice.label} `} active>
198
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
199
+ <text content={choice.prompt} fg={theme.accent} />
200
+ <input
201
+ focused
202
+ value={value}
203
+ placeholder={choice.placeholder}
204
+ onInput={(v: string) => setValue(v)}
205
+ onSubmit={() => value.trim() && makePlan({ [choice.key]: value.trim() })}
206
+ style={INPUT_STYLE}
207
+ />
208
+ <box style={{ height: 1 }} />
209
+ <text content={choice.key === "clone" ? "Nothing changes until you've seen the plan." : "Keep it private: reports name your sites and clients. Nothing changes until you've seen the plan."} fg={theme.textFaint} />
210
+ </box>
211
+ </Panel>
212
+ )
213
+ }
214
+ if (phase === "blocked" && plan) {
215
+ return (
216
+ <Panel title=" Can't share it yet " active>
217
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
218
+ {plan.blockers.map((b) => (
219
+ <box key={b.reason + b.message} style={{ flexDirection: "column" }}>
220
+ <text content={`✕ ${b.message}`} fg={theme.bad} />
221
+ {b.remedy && <text content={` ${b.remedy}`} fg={theme.textDim} />}
222
+ <box style={{ height: 1 }} />
223
+ </box>
224
+ ))}
225
+ </box>
226
+ </Panel>
227
+ )
228
+ }
229
+ if (phase === "confirm" && plan) {
230
+ if (!plan.steps.length) {
231
+ return (
232
+ <Panel title=" Shared " active>
233
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
234
+ {header()}
235
+ <text content="✓ Shared and up to date. Nothing to do." fg={theme.good} />
236
+ <box style={{ height: 1 }} />
237
+ <text content={`On another machine: spinuptui home sync --clone ${status?.remote ?? "<url>"} --plan`} fg={theme.textFaint} />
238
+ {plan.notes.map((n) => (
239
+ <text key={n} content={n} fg={theme.warn} />
240
+ ))}
241
+ </box>
242
+ </Panel>
243
+ )
244
+ }
245
+ return (
246
+ <Panel title=" The plan " active>
247
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
248
+ {header()}
249
+ {plan.steps.map((s, i) => (
250
+ <text key={s.label} content={`${i + 1}. ${truncate(s.label, 70)}`} fg={theme.text} wrapMode="none" />
251
+ ))}
252
+ <box style={{ height: 1 }} />
253
+ {plan.notes.map((n) => (
254
+ <text key={n} content={n} fg={theme.textFaint} />
255
+ ))}
256
+ <box style={{ height: 1 }} />
257
+ <text content="Press y to do it · Esc to cancel" fg={theme.text} wrapMode="none" />
258
+ </box>
259
+ </Panel>
260
+ )
261
+ }
262
+ if (phase === "done" && result) {
263
+ return (
264
+ <Panel title=" Shared " active>
265
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
266
+ {result.done.map((d) => (
267
+ <text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
268
+ ))}
269
+ <box style={{ height: 1 }} />
270
+ {status && <text content={`${tilde(status.path)} is ${truncate(describeShare(status), 56)}.`} fg={theme.text} />}
271
+ <box style={{ height: 1 }} />
272
+ <text content="Esc to close" fg={theme.textFaint} />
273
+ </box>
274
+ </Panel>
275
+ )
276
+ }
277
+ return (
278
+ <Panel title=" Stopped " active>
279
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
280
+ {result?.done.map((d) => (
281
+ <text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
282
+ ))}
283
+ <text content={`✕ ${result?.failed?.step ?? "Something went wrong"}: ${result?.failed?.message ?? ""}`} fg={theme.bad} />
284
+ <box style={{ height: 1 }} />
285
+ <text content="The steps above it are done. Fix this and run it again: it picks up where it stopped." fg={theme.textDim} />
286
+ </box>
287
+ </Panel>
288
+ )
289
+ }
290
+
291
+ function hints() {
292
+ const open = status?.exists ? [{ key: "o", label: "open folder" }] : []
293
+ if (phase === "pick") return [{ key: "↑↓", label: "choose" }, { key: "⏎", label: "next" }, ...open, { key: "esc", label: "close" }]
294
+ if (phase === "input") return [{ key: "⏎", label: "see the plan" }, { key: "esc", label: "back" }]
295
+ if (phase === "confirm" && plan?.steps.length) return [{ key: "y", label: "do it" }, ...open, { key: "esc", label: "cancel" }]
296
+ if (phase === "blocked" || phase === "error") return [...(status?.git && status.remote ? [] : [{ key: "r", label: "back" }]), ...open, { key: "esc", label: "close" }]
297
+ return [...open, { key: "esc", label: "close" }]
298
+ }
299
+ }