spinuptui 0.26.1 → 0.27.0

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.
@@ -3,21 +3,25 @@
3
3
  // Left pane: selectable composition groups. The three top-level buckets
4
4
  // (Standard WP / Bedrock / Non-WP) use the EFFECTIVE stack — a conclusive Tier-2
5
5
  // probe overrides the API heuristic — so counts drift toward reality as you
6
- // probe. Non-WP expands into named sub-rows (WHMCS / Laravel / Static HTML /
7
- // Unknown / unprobed) from cached probes.
6
+ // probe. The WordPress buckets (Standard WP / Bedrock / Radicle) expand into one
7
+ // sub-row per probed core version, newest first — the "who still needs the
8
+ // security release" view; Non-WP expands into named sub-rows (WHMCS / Laravel /
9
+ // Static HTML / Unknown / unprobed). Both come from cached probes.
8
10
  //
9
11
  // Middle pane: the sites in the selected group. `d` probes the selected site;
10
12
  // `D` probes the whole selected group (bounded concurrency). Right pane: the
11
13
  // fleet-wide PHP version distribution with EOL versions flagged.
12
14
 
13
- import { useEffect, useMemo, useState } from "react"
15
+ import { useEffect, useMemo, useRef, useState } from "react"
14
16
  import { useKeyboard } from "@opentui/react"
15
17
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
16
18
  import { bar, truncate } from "../../lib/format.ts"
17
19
  import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
18
20
  import { phpSortKey } from "../../lib/phpEol.ts"
19
21
  import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
22
+ import { compareVersions } from "../../lib/wpCore.ts"
20
23
  import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
24
+ import { WpCoreRowMark, clearBulk, peekBulk, useWpCoreJobs } from "../wpCoreJobs.tsx"
21
25
  import { List, moveSelection } from "../List.tsx"
22
26
  import { StatusBar } from "../StatusBar.tsx"
23
27
  import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
@@ -29,15 +33,20 @@ import type { Site } from "../../api/types.ts"
29
33
  type Focus = "groups" | "sites"
30
34
 
31
35
  // A selectable row in the composition pane: a top-level bucket (level 0) or a
32
- // Non-WP sub-category (level 1).
36
+ // sub-row (level 1) — a WP core version under a WordPress stack, or a Non-WP
37
+ // sub-category.
33
38
  interface Group {
34
39
  id: string
35
40
  label: string
41
+ title?: string // sites-pane title when the bare label lacks context ("7.0.2" → "Bedrock · WP 7.0.2")
36
42
  level: 0 | 1
37
43
  color: string
38
44
  sites: Site[]
39
45
  }
40
46
 
47
+ const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
48
+
49
+
41
50
  // Non-WP sub-categories, in display order. `kind: null` = not yet probed.
42
51
  const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
43
52
  { kind: "whmcs", label: "WHMCS" },
@@ -49,10 +58,12 @@ const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
49
58
 
50
59
  export function Stacks({ rows }: { rows: number }) {
51
60
  const store = useStore()
52
- const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
61
+ const wpCoreJobs = useWpCoreJobs()
62
+ const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
53
63
  store
54
64
 
55
65
  const [groupIndex, setGroupIndex] = useState(0)
66
+ const groupTopRef = useRef(0)
56
67
  const [siteIndex, setSiteIndex] = useState(0)
57
68
  const [focus, setFocus] = useState<Focus>("groups")
58
69
  const [flash, setFlash] = useState<string | null>(null)
@@ -75,10 +86,43 @@ export function Stacks({ rows }: { rows: number }) {
75
86
  }
76
87
  for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
77
88
 
89
+ // The newest core version seen anywhere in the fleet — the one every
90
+ // version row is measured against (highlighted; older rows are dimmed).
91
+ let newestWp: string | null = null
92
+ for (const st of WP_STACKS) {
93
+ for (const site of byStack.get(st)!) {
94
+ const v = probes.get(site.id)?.result.version
95
+ if (v && (!newestWp || compareVersions(v, newestWp) > 0)) newestWp = v
96
+ }
97
+ }
98
+
78
99
  const groups: Group[] = []
79
100
  for (const st of STACKS) {
80
101
  const bucket = byStack.get(st)!
81
102
  groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
103
+ if (WP_STACKS.includes(st)) {
104
+ const byVersion = new Map<string, Site[]>()
105
+ const noVersion: Site[] = []
106
+ for (const site of bucket) {
107
+ const v = probes.get(site.id)?.result.version
108
+ if (v) byVersion.set(v, [...(byVersion.get(v) ?? []), site])
109
+ else noVersion.push(site)
110
+ }
111
+ for (const v of [...byVersion.keys()].sort((a, b) => compareVersions(b, a))) {
112
+ groups.push({
113
+ id: `wp:${st}:${v}`,
114
+ label: `WP ${v}`,
115
+ title: `${st} · WP ${v}`,
116
+ level: 1,
117
+ color: v === newestWp ? theme.good : theme.textDim,
118
+ sites: byVersion.get(v)!,
119
+ })
120
+ }
121
+ // Only worth a row when some are versioned — otherwise it'd just repeat the bucket.
122
+ if (noVersion.length && byVersion.size) {
123
+ groups.push({ id: `wp:${st}:none`, label: "no version", title: `${st} · version unknown`, level: 1, color: theme.textFaint, sites: noVersion })
124
+ }
125
+ }
82
126
  if (st === "Non-WP") {
83
127
  for (const sub of NONWP_SUBS) {
84
128
  const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
@@ -101,6 +145,8 @@ export function Stacks({ rows }: { rows: number }) {
101
145
  const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
102
146
  const selectedGroup = groups[safeGroupIndex]
103
147
  const groupSites = selectedGroup?.sites ?? []
148
+ // A "WP x.y.z" (or "no version") row under a WordPress stack — bulk-updatable.
149
+ const isVersionGroup = !!selectedGroup && selectedGroup.id.startsWith("wp:")
104
150
  const total = sites.length || 1
105
151
 
106
152
  useEffect(() => {
@@ -167,8 +213,20 @@ export function Stacks({ rows }: { rows: number }) {
167
213
  }
168
214
  return
169
215
  case "u":
170
- // Upgrade the selected site's PHP version (sites pane only).
216
+ // Sites pane: the selected site's Update menu. Groups pane, on a WP
217
+ // version row: bulk-update that whole group (a running or unseen bulk
218
+ // run takes over the overlay instead — only one exists at a time).
171
219
  if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
220
+ else if (focus === "groups" && isVersionGroup && groupSites.length) {
221
+ const title = selectedGroup.title ?? selectedGroup.label
222
+ // A finished run's summary belongs to its own group: reopening THAT
223
+ // group shows it, any other group starts fresh (and the old summary is
224
+ // dropped). A run still in progress takes over regardless — one at a time.
225
+ const prev = peekBulk()
226
+ if (prev?.phase === "done" && prev.title !== title) clearBulk()
227
+ setBulkWpGroup({ title, sites: groupSites })
228
+ }
229
+ else if (focus === "groups") flashMsg("u updates a WordPress version group — pick a “WP x.y.z” row, or a site")
172
230
  return
173
231
  case "H":
174
232
  // Enable/disable HTTPS on the selected site (sites pane only).
@@ -224,6 +282,13 @@ export function Stacks({ rows }: { rows: number }) {
224
282
  })
225
283
 
226
284
  const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
285
+ // Version sub-rows can outgrow a short terminal — scroll the groups pane just
286
+ // enough to keep the selection visible (same viewport as the sites list).
287
+ // Only moves when the selection leaves the viewport, like List does.
288
+ let groupTop = Math.min(groupTopRef.current, Math.max(0, groups.length - listRows))
289
+ if (safeGroupIndex < groupTop) groupTop = safeGroupIndex
290
+ else if (safeGroupIndex >= groupTop + listRows) groupTop = safeGroupIndex - listRows + 1
291
+ groupTopRef.current = groupTop
227
292
  const maxPhp = Math.max(1, ...php.map(([, n]) => n))
228
293
 
229
294
  const hints =
@@ -231,6 +296,7 @@ export function Stacks({ rows }: { rows: number }) {
231
296
  ? [
232
297
  { key: "↑↓/jk", label: "select" },
233
298
  { key: "→/⏎", label: "view sites" },
299
+ ...(isVersionGroup ? [{ key: "u", label: `update ${groupSites.length} ${groupSites.length === 1 ? "site" : "sites"}` }] : []),
234
300
  { key: "D", label: "identify all" },
235
301
  { key: "S", label: "find local copies" },
236
302
  { key: "f", label: "needs local copy" },
@@ -239,7 +305,7 @@ export function Stacks({ rows }: { rows: number }) {
239
305
  { key: "↑↓/jk", label: "select site" },
240
306
  { key: "←/esc", label: "back" },
241
307
  { key: "d", label: "identify app" },
242
- { key: "u", label: "change PHP" },
308
+ { key: "u", label: "update" },
243
309
  { key: "o", label: "open" },
244
310
  { key: "w", label: "SpinupWP" },
245
311
  { key: "s", label: "ssh" },
@@ -257,7 +323,8 @@ export function Stacks({ rows }: { rows: number }) {
257
323
  {/* Composition groups (top-level buckets + Non-WP sub-rows) */}
258
324
  <Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
259
325
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
260
- {groups.map((grp, i) => {
326
+ {groups.slice(groupTop, groupTop + listRows).map((grp, j) => {
327
+ const i = groupTop + j
261
328
  const n = grp.sites.length
262
329
  const selected = i === safeGroupIndex
263
330
  const sel = selected && focus === "groups"
@@ -291,7 +358,7 @@ export function Stacks({ rows }: { rows: number }) {
291
358
  </Panel>
292
359
 
293
360
  {/* Sites in the selected group */}
294
- <Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
361
+ <Panel title={` ${selectedGroup?.title ?? selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
295
362
  <List
296
363
  items={groupSites}
297
364
  selectedIndex={siteIndex}
@@ -303,6 +370,7 @@ export function Stacks({ rows }: { rows: number }) {
303
370
  const cached = probes.get(s.id)
304
371
  const probing = probingIds.has(s.id)
305
372
  const errored = probeErrors.has(s.id)
373
+ const wpRunning = wpCoreJobs.get(s.id)?.result === null
306
374
  const faint = selected ? theme.text : theme.textFaint
307
375
  const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
308
376
  return (
@@ -318,7 +386,10 @@ export function Stacks({ rows }: { rows: number }) {
318
386
  <SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
319
387
  </box>
320
388
  <box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
321
- {probing ? (
389
+ {wpRunning ? (
390
+ // A core update in flight replaces the version it's about to change.
391
+ <WpCoreRowMark siteId={s.id} selected={selected} />
392
+ ) : probing ? (
322
393
  <Spinner color={selected ? theme.text : theme.brand} />
323
394
  ) : cached ? (
324
395
  <text
@@ -331,6 +402,8 @@ export function Stacks({ rows }: { rows: number }) {
331
402
  ) : (
332
403
  <text content="· press d" fg={faint} wrapMode="none" />
333
404
  )}
405
+ {/* Failed: keep the (unchanged) version, flag it — like PHP's ⬆!. */}
406
+ {!wpRunning && <WpCoreRowMark siteId={s.id} selected={selected} />}
334
407
  </box>
335
408
  <text
336
409
  content={truncate(serverById(s.server_id)?.name ?? "", 16)}
@@ -0,0 +1,322 @@
1
+ // WordPress core update — the "WordPress core" branch of the `u` Update menu.
2
+ //
3
+ // check (SSH, read-only) → pick a version → confirm → update → done/failed. The
4
+ // work itself lives in lib/wpCore.ts; the job is tracked in ../wpCoreJobs.tsx, so
5
+ // Esc doesn't abandon it: the SSH run keeps going, a toast reports the outcome,
6
+ // and reopening the overlay on the same site picks the job back up.
7
+
8
+ import { useEffect, useState } from "react"
9
+ import { useKeyboard } from "@opentui/react"
10
+ import { theme } from "../../lib/theme.ts"
11
+ import { truncate } from "../../lib/format.ts"
12
+ import { Panel, Spinner, Centered } from "../components.tsx"
13
+ import { StatusBar } from "../StatusBar.tsx"
14
+ import { useStore } from "../store.tsx"
15
+ import { moveSelection } from "../List.tsx"
16
+ import type { Site } from "../../api/types.ts"
17
+ import { checkWpCore, type WpCoreStatus } from "../../lib/wpCore.ts"
18
+ import { setCoreJob, startCoreUpdate, useWpCoreJob } from "../wpCoreJobs.tsx"
19
+
20
+ // ---- view -------------------------------------------------------------------
21
+
22
+ type Phase = "checking" | "checkError" | "upToDate" | "pick" | "confirm"
23
+
24
+ export function WpCoreUpdate({ site, onBack, onClose }: { site: Site; onBack: () => void; onClose: () => void }) {
25
+ const { serverById, sshUser, noteWpVersion } = useStore()
26
+ const server = serverById(site.server_id)
27
+ const job = useWpCoreJob(site.id)
28
+
29
+ const [phase, setPhase] = useState<Phase>("checking")
30
+ const [status, setStatus] = useState<WpCoreStatus | null>(null)
31
+ const [checkError, setCheckError] = useState("")
32
+ const [index, setIndex] = useState(0)
33
+ const [nonce, setNonce] = useState(0) // bump to re-check
34
+
35
+ useEffect(() => {
36
+ if (!server) {
37
+ setCheckError("This site's server isn't loaded.")
38
+ setPhase("checkError")
39
+ return
40
+ }
41
+ let live = true
42
+ setPhase("checking")
43
+ void checkWpCore(server, site, sshUser).then((res) => {
44
+ if (!live) return
45
+ if (!res.ok) {
46
+ setCheckError(`${res.error} (${res.target})`)
47
+ setPhase("checkError")
48
+ return
49
+ }
50
+ setStatus(res.status)
51
+ // A fresh live read — correct any stale row while we're here, update or not.
52
+ noteWpVersion(site, res.status.current)
53
+ // Land on the first minor (security/maintenance) release when there is one
54
+ // — the usual reason to be here — rather than a major jump.
55
+ const minor = res.status.offers.findIndex((o) => o.updateType === "minor")
56
+ setIndex(minor >= 0 ? minor : 0)
57
+ setPhase(res.status.offers.length ? "pick" : "upToDate")
58
+ })
59
+ return () => {
60
+ live = false
61
+ }
62
+ // Keyed on ids, not objects: a background store refresh hands back a new
63
+ // server object, and re-checking then would yank the user out of pick/confirm.
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [server?.id, site.id, sshUser, nonce])
66
+
67
+ const offers = status?.offers ?? []
68
+ const target = offers[index]
69
+ // A job for this site overrides the local flow: running → progress; settled → its result.
70
+ const view: Phase | "running" | "done" | "failed" = job ? (job.result === null ? "running" : job.result.ok ? "done" : "failed") : phase
71
+
72
+ useKeyboard((key) => {
73
+ const name = key.name ?? ""
74
+ if (name === "escape" || name === "q") {
75
+ if (job?.result) setCoreJob(site.id, null) // the outcome's been seen — don't replay it next time
76
+ return onClose()
77
+ }
78
+ if (view === "done" || view === "failed") {
79
+ if (name === "r" || (view === "done" && (name === "left" || name === "h"))) {
80
+ setCoreJob(site.id, null)
81
+ setNonce((n) => n + 1)
82
+ }
83
+ return
84
+ }
85
+ if (view === "running") return
86
+ if (view === "checking" || view === "checkError" || view === "upToDate") {
87
+ if (name === "left" || name === "h") return onBack()
88
+ if (name === "r" && view !== "checking") setNonce((n) => n + 1)
89
+ return
90
+ }
91
+ if (view === "pick") {
92
+ switch (name) {
93
+ case "up":
94
+ case "k":
95
+ return setIndex((i) => moveSelection(i, -1, offers.length))
96
+ case "down":
97
+ case "j":
98
+ return setIndex((i) => moveSelection(i, 1, offers.length))
99
+ case "left":
100
+ case "h":
101
+ return onBack()
102
+ case "return":
103
+ case "right":
104
+ case "l":
105
+ if (target) setPhase("confirm")
106
+ return
107
+ }
108
+ return
109
+ }
110
+ if (view === "confirm") {
111
+ if (name === "y" && server && status && target) startCoreUpdate(server, site, sshUser, target.version, status.multisite, noteWpVersion)
112
+ else if (name === "left" || name === "h") setPhase("pick")
113
+ }
114
+ })
115
+
116
+ return (
117
+ <box
118
+ style={{
119
+ position: "absolute",
120
+ top: 0,
121
+ left: 0,
122
+ width: "100%",
123
+ height: "100%",
124
+ flexDirection: "column",
125
+ backgroundColor: theme.bg,
126
+ zIndex: 210,
127
+ }}
128
+ >
129
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
130
+ <text content="⬆ Update WordPress " fg={theme.brand} style={{ flexShrink: 0 }} />
131
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
132
+ <box style={{ flexGrow: 1 }} />
133
+ <text content={status ? `current WP ${status.current}` : ""} fg={theme.textFaint} style={{ flexShrink: 0 }} />
134
+ </box>
135
+
136
+ <Centered>{renderBody()}</Centered>
137
+
138
+ <StatusBar hints={hints()} showGlobal={false} />
139
+ </box>
140
+ )
141
+
142
+ function composerLines(): string[] {
143
+ if (!status?.composer) return []
144
+ const pin = status.composer.lockedVersion ? `composer.lock pins ${status.composer.lockedVersion}` : "Composer manages core"
145
+ return [
146
+ `Core is installed by Composer (${pin}). This patches the`,
147
+ "files on the server only — the next composer install (a deploy,",
148
+ "a clone) puts the locked version back. Run composer update and",
149
+ "commit the lock to make it stick.",
150
+ ]
151
+ }
152
+
153
+ function renderBody() {
154
+ if (view === "checking") {
155
+ return (
156
+ <box style={{ flexDirection: "row" }}>
157
+ <Spinner />
158
+ <text content=" Checking WordPress over SSH…" fg={theme.textDim} />
159
+ </box>
160
+ )
161
+ }
162
+
163
+ if (view === "checkError") {
164
+ return (
165
+ <Panel title=" Couldn't check WordPress " active>
166
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
167
+ <text content={`✕ ${checkError}`} fg={theme.bad} />
168
+ <box style={{ height: 1 }} />
169
+ <text content="r to retry · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
170
+ </box>
171
+ </Panel>
172
+ )
173
+ }
174
+
175
+ if (view === "upToDate") {
176
+ return (
177
+ <Panel title=" Up to date " active>
178
+ <box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
179
+ <box style={{ flexDirection: "row" }}>
180
+ <text content="✓ " fg={theme.good} />
181
+ <text content={`WordPress ${status?.current} is the latest version.`} fg={theme.text} wrapMode="none" />
182
+ </box>
183
+ <box style={{ height: 1 }} />
184
+ <text content="r to re-check · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
185
+ </box>
186
+ </Panel>
187
+ )
188
+ }
189
+
190
+ if (view === "pick") {
191
+ return (
192
+ <Panel title=" Choose a WordPress version " active>
193
+ <box style={{ flexDirection: "column", width: 56 }}>
194
+ {offers.map((o, i) => {
195
+ const selected = i === index
196
+ const tag = o.updateType ? ` (${o.updateType})` : ""
197
+ return (
198
+ <box key={o.version} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
199
+ <text content={(selected ? "❯ " : " ") + `WordPress ${o.version}`} fg={selected ? theme.text : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
200
+ <text content={tag} fg={selected ? theme.text : o.updateType === "major" ? theme.warn : theme.textFaint} style={{ flexShrink: 0 }} />
201
+ </box>
202
+ )
203
+ })}
204
+ <box style={{ height: 1 }} />
205
+ <text content={`Installed: WordPress ${status?.current}`} fg={theme.textFaint} wrapMode="none" />
206
+ {status?.composer ? <text content="Composer-managed core — see the note on the next screen." fg={theme.warn} wrapMode="none" /> : null}
207
+ </box>
208
+ </Panel>
209
+ )
210
+ }
211
+
212
+ if (view === "confirm" && target && status) {
213
+ return (
214
+ <Panel title=" Confirm update " active>
215
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
216
+ <box style={{ flexDirection: "row" }}>
217
+ <text content="Update " fg={theme.text} />
218
+ <text content={truncate(site.domain, 40)} fg={theme.accent} wrapMode="none" />
219
+ </box>
220
+ <box style={{ flexDirection: "row" }}>
221
+ <text content={`from WordPress ${status.current}`} fg={theme.textDim} />
222
+ <text content=" → " fg={theme.textFaint} />
223
+ <text content={`WordPress ${target.version}`} fg={theme.good} />
224
+ </box>
225
+ <box style={{ height: 1 }} />
226
+ <text content={`Runs wp core update, then the database upgrade${status.multisite ? " (network-wide)" : ""}.`} fg={theme.textDim} wrapMode="none" />
227
+ {target.updateType === "major" ? <text content="Major release — test plugins and themes against it." fg={theme.warn} wrapMode="none" /> : null}
228
+ {composerLines().length ? <box style={{ height: 1 }} /> : null}
229
+ {composerLines().map((l) => (
230
+ <text key={l} content={l} fg={theme.warn} wrapMode="none" />
231
+ ))}
232
+ <box style={{ height: 1 }} />
233
+ <text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
234
+ </box>
235
+ </Panel>
236
+ )
237
+ }
238
+
239
+ if (view === "running") {
240
+ return (
241
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
242
+ <box style={{ flexDirection: "row" }}>
243
+ <Spinner />
244
+ <text content={` Updating to WordPress ${job?.version}…`} fg={theme.textDim} />
245
+ </box>
246
+ <box style={{ height: 1 }} />
247
+ <text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
248
+ </box>
249
+ )
250
+ }
251
+
252
+ if (view === "done" && job?.result?.ok) {
253
+ return (
254
+ <Panel title=" Done " active>
255
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
256
+ <box style={{ flexDirection: "row" }}>
257
+ <text content="✓ " fg={theme.good} />
258
+ <text content={truncate(site.domain, 32)} fg={theme.accent} wrapMode="none" />
259
+ <text content={` is now on WordPress ${job.result.after}`} fg={theme.text} wrapMode="none" />
260
+ </box>
261
+ <text content={`(was ${job.result.before})`} fg={theme.textFaint} wrapMode="none" />
262
+ {composerLines().length ? <box style={{ height: 1 }} /> : null}
263
+ {composerLines().map((l) => (
264
+ <text key={l} content={l} fg={theme.warn} wrapMode="none" />
265
+ ))}
266
+ <box style={{ height: 1 }} />
267
+ <text content="Esc to close" fg={theme.textFaint} />
268
+ </box>
269
+ </Panel>
270
+ )
271
+ }
272
+
273
+ // failed
274
+ const result = job?.result && !job.result.ok ? job.result : null
275
+ const tail = (result?.log ?? "").split("\n").filter(Boolean).slice(-8)
276
+ return (
277
+ <Panel title=" Update failed " active>
278
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
279
+ <text content={`✕ ${result?.error ?? "Something went wrong."}`} fg={theme.bad} />
280
+ {tail.length ? <box style={{ height: 1 }} /> : null}
281
+ {tail.map((l, i) => (
282
+ <text key={i} content={truncate(l, 74)} fg={theme.textDim} wrapMode="none" />
283
+ ))}
284
+ <box style={{ height: 1 }} />
285
+ <text content="Press r to re-check the site · Esc to close" fg={theme.textFaint} wrapMode="none" />
286
+ </box>
287
+ </Panel>
288
+ )
289
+ }
290
+
291
+ function hints() {
292
+ switch (view) {
293
+ case "pick":
294
+ return [
295
+ { key: "↑↓/jk", label: "version" },
296
+ { key: "⏎", label: "choose" },
297
+ { key: "←", label: "back" },
298
+ { key: "esc", label: "cancel" },
299
+ ]
300
+ case "confirm":
301
+ return [
302
+ { key: "y", label: "confirm" },
303
+ { key: "←", label: "back" },
304
+ { key: "esc", label: "cancel" },
305
+ ]
306
+ case "checkError":
307
+ case "upToDate":
308
+ return [
309
+ { key: "r", label: "re-check" },
310
+ { key: "←", label: "back" },
311
+ { key: "esc", label: "close" },
312
+ ]
313
+ case "failed":
314
+ return [
315
+ { key: "r", label: "re-check" },
316
+ { key: "esc", label: "close" },
317
+ ]
318
+ default:
319
+ return [{ key: "esc", label: "close" }]
320
+ }
321
+ }
322
+ }