| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | /** |
| 3 | * GUI-style dashboard behind bare `alchemy profile`. One Sigil app stays |
| 4 | * mounted for the whole session and screens replace each other in place: |
| 5 | * |
| 6 | * overview — chip tabs (default profile first), selected profile's |
| 7 | * provider details with an up/down focus cursor, keybind bar, |
| 8 | * inline rename/new/delete/remove |
| 9 | * edit — replaces the overview: per-provider cycle rows |
| 10 | * (keep / reconfigure / remove, add for unconnected) |
| 11 | * |
| 12 | * Pure store actions (create/rename/delete) round-trip through |
| 13 | * the hub via a request bridge WITHOUT unmounting — results come back as |
| 14 | * in-app notices. Only flows that must prompt in the transcript (provider |
| 15 | * configuration, credential refresh) resolve the session. The built-in |
| 16 | * `default` profile is pinned first and can be neither renamed nor deleted. |
| 17 | */ |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Fiber from "effect/Fiber"; |
| 20 | import * as Scheduler from "effect/Scheduler"; |
| 21 | import { useEffect, useState } from "@alchemy.run/sigil/react"; |
| 22 | import type { JSX } from "react"; |
| 23 | import { |
| 24 | Alert, |
| 25 | Box, |
| 26 | CycleList, |
| 27 | Gutter, |
| 28 | InlineConfirm, |
| 29 | KeyBar, |
| 30 | LiveStore, |
| 31 | Pointer, |
| 32 | PromptFrame, |
| 33 | Spinner, |
| 34 | Stack, |
| 35 | Status, |
| 36 | Tabs, |
| 37 | Text, |
| 38 | TextField, |
| 39 | Toast, |
| 40 | useCycleNavigation, |
| 41 | useGlyphs, |
| 42 | useKeyGlyphs, |
| 43 | useLiveStore, |
| 44 | useTerminalInput, |
| 45 | useTerminalSize, |
| 46 | VirtualList, |
| 47 | } from "../ui/index.ts"; |
| 48 | import { |
| 49 | CliKit, |
| 50 | theme, |
| 51 | type NonInteractiveTerminal, |
| 52 | } from "../../CliKit/index.ts"; |
| 53 | import { |
| 54 | type EditState, |
| 55 | editStateStyle, |
| 56 | ProviderBlock, |
| 57 | providerBlockHeight, |
| 58 | providerColumnWidths, |
| 59 | type ProfileProviderDisplay, |
| 60 | } from "./Profile.tsx"; |
| 61 | |
| 62 | // --- data contracts --------------------------------------------------------- |
| 63 | |
| 64 | export interface DashboardEntry { |
| 65 | readonly name: string; |
| 66 | readonly isActive: boolean; |
| 67 | readonly isDefault: boolean; |
| 68 | } |
| 69 | |
| 70 | export interface ProfileDetailsPayload { |
| 71 | readonly providers: ReadonlyArray<ProfileProviderDisplay>; |
| 72 | /** Registry providers not yet connected to this profile. */ |
| 73 | readonly available: ReadonlyArray<string>; |
| 74 | } |
| 75 | |
| 76 | export type PureAction = |
| 77 | | { kind: "create"; name: string } |
| 78 | | { kind: "rename"; name: string; newName: string } |
| 79 | | { kind: "delete"; name: string }; |
| 80 | |
| 81 | export type FlowAction = |
| 82 | | { |
| 83 | kind: "edit-apply"; |
| 84 | name: string; |
| 85 | add: string[]; |
| 86 | reconfigure: string[]; |
| 87 | remove: string[]; |
| 88 | } |
| 89 | | { kind: "refresh"; name: string; provider?: string }; |
| 90 | |
| 91 | export type ExternalAction = FlowAction | { kind: "exit" }; |
| 92 | |
| 93 | export interface ExecuteResult { |
| 94 | readonly ok: boolean; |
| 95 | readonly message: string; |
| 96 | readonly entries: ReadonlyArray<DashboardEntry>; |
| 97 | /** Profile to focus after the action (e.g. the new name of a rename). */ |
| 98 | readonly selected?: string; |
| 99 | } |
| 100 | |
| 101 | // --- store ------------------------------------------------------------------ |
| 102 | |
| 103 | export type Details = |
| 104 | | { state: "loading" } |
| 105 | | ({ state: "ready" } & ProfileDetailsPayload) |
| 106 | | { state: "failed"; message: string }; |
| 107 | |
| 108 | interface Notice { |
| 109 | readonly ok: boolean; |
| 110 | readonly message: string; |
| 111 | } |
| 112 | |
| 113 | interface Flow { |
| 114 | readonly kind: FlowAction["kind"]; |
| 115 | readonly name: string; |
| 116 | readonly provider?: string; |
| 117 | /** Inline flows render within the overview (refresh spinner). */ |
| 118 | readonly inline: boolean; |
| 119 | } |
| 120 | |
| 121 | interface DashState { |
| 122 | readonly entries: ReadonlyArray<DashboardEntry>; |
| 123 | readonly details: ReadonlyMap<string, Details>; |
| 124 | readonly notice: Notice | undefined; |
| 125 | readonly busy: boolean; |
| 126 | /** One-shot tab focus request (e.g. follow a rename to its new name). */ |
| 127 | readonly focus: string | undefined; |
| 128 | /** Active in-app flow; `inline` flows render within the overview. */ |
| 129 | readonly flow: Flow | undefined; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Immutable-snapshot store on top of `LiveStore`, so every mutation notifies |
| 134 | * the mounted dashboard. The resolver bridge and the notice auto-dismiss |
| 135 | * timer live outside the snapshot — they carry no visual state. |
| 136 | */ |
| 137 | export class DashStore extends LiveStore<DashState> { |
| 138 | private resolver: ((action: PureAction | ExternalAction) => void) | null = |
| 139 | null; |
| 140 | |
| 141 | constructor(entries: ReadonlyArray<DashboardEntry>) { |
| 142 | super({ |
| 143 | entries, |
| 144 | details: new Map(), |
| 145 | notice: undefined, |
| 146 | busy: false, |
| 147 | focus: undefined, |
| 148 | flow: undefined, |
| 149 | }); |
| 150 | } |
| 151 | |
| 152 | detailsFor(name: string): Details { |
| 153 | return this.snapshot().details.get(name) ?? { state: "loading" }; |
| 154 | } |
| 155 | detailNames(): ReadonlySet<string> { |
| 156 | return new Set(this.snapshot().details.keys()); |
| 157 | } |
| 158 | setDetails(name: string, details: Details) { |
| 159 | this.update((state) => ({ |
| 160 | ...state, |
| 161 | details: new Map(state.details).set(name, details), |
| 162 | })); |
| 163 | } |
| 164 | setFlow(flow: Flow | undefined) { |
| 165 | this.update((state) => ({ ...state, flow })); |
| 166 | } |
| 167 | clearFocus() { |
| 168 | this.update((state) => ({ ...state, focus: undefined })); |
| 169 | } |
| 170 | dispose() { |
| 171 | this.resolver = null; |
| 172 | } |
| 173 | clearNotice() { |
| 174 | this.update((state) => ({ ...state, notice: undefined })); |
| 175 | } |
| 176 | applyResult(result: ExecuteResult) { |
| 177 | this.update((state) => ({ |
| 178 | ...state, |
| 179 | entries: result.entries, |
| 180 | notice: { ok: result.ok, message: result.message }, |
| 181 | focus: result.ok ? result.selected : undefined, |
| 182 | busy: false, |
| 183 | })); |
| 184 | } |
| 185 | bindResolver(resolve: (action: PureAction | ExternalAction) => void) { |
| 186 | this.resolver = resolve; |
| 187 | } |
| 188 | readonly dispatch = (action: PureAction | ExternalAction) => { |
| 189 | if (this.snapshot().busy || this.resolver === null) return; |
| 190 | const resolve = this.resolver; |
| 191 | this.resolver = null; |
| 192 | this.update((state) => ({ ...state, busy: true, notice: undefined })); |
| 193 | resolve(action); |
| 194 | }; |
| 195 | } |
| 196 | |
| 197 | // --- building blocks -------------------------------------------------------- |
| 198 | |
| 199 | type DetailsPaneProps = { |
| 200 | details: Details; |
| 201 | refreshingProvider?: string; |
| 202 | /** Index into `details.providers` the up/down cursor rests on. */ |
| 203 | focusedIndex: number; |
| 204 | }; |
| 205 | |
| 206 | function DetailsPane({ |
| 207 | details, |
| 208 | refreshingProvider, |
| 209 | focusedIndex, |
| 210 | }: DetailsPaneProps): JSX.Element { |
| 211 | if (details.state === "loading") { |
| 212 | return <Spinner label="resolving credentials…" />; |
| 213 | } |
| 214 | if (details.state === "failed") { |
| 215 | return <Status variant="error">{details.message}</Status>; |
| 216 | } |
| 217 | if (details.providers.length === 0) { |
| 218 | return ( |
| 219 | <Text tone="muted">No accounts connected — press e to add one.</Text> |
| 220 | ); |
| 221 | } |
| 222 | const { providers } = details; |
| 223 | const { nameWidth, methodWidth } = providerColumnWidths(providers); |
| 224 | const focusedProvider = providers[focusedIndex]?.name; |
| 225 | const reauthHint = "press r to re-login"; |
| 226 | // The same blocks `profile show` prints, windowed to the rows the terminal |
| 227 | // leaves the pane: the list shrinks to fit (see the root layout in |
| 228 | // `Dashboard`) and scrolls just enough to keep the focused provider in view. |
| 229 | // The profile-level slot (no focused provider) shows the list from the top. |
| 230 | return ( |
| 231 | <Box flexDirection="column" minHeight={0}> |
| 232 | <VirtualList |
| 233 | items={providers} |
| 234 | getKey={(provider) => provider.name} |
| 235 | itemHeight={(provider, index) => |
| 236 | providerBlockHeight(provider, index === 0) |
| 237 | } |
| 238 | focusedIndex={Math.max(0, focusedIndex)} |
| 239 | renderItem={(provider, index) => ( |
| 240 | <ProviderBlock |
| 241 | provider={provider} |
| 242 | first={index === 0} |
| 243 | nameWidth={nameWidth} |
| 244 | methodWidth={methodWidth} |
| 245 | reauthHint={reauthHint} |
| 246 | refreshingProvider={refreshingProvider} |
| 247 | focusedProvider={focusedProvider} |
| 248 | focusColumn |
| 249 | /> |
| 250 | )} |
| 251 | /> |
| 252 | </Box> |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | // --- edit screen ------------------------------------------------------------ |
| 257 | |
| 258 | interface EditRow { |
| 259 | readonly provider: string; |
| 260 | readonly method: string | undefined; // undefined = not connected |
| 261 | readonly states: ReadonlyArray<EditState>; |
| 262 | } |
| 263 | |
| 264 | type EditScreenProps = { |
| 265 | profile: string; |
| 266 | rows: ReadonlyArray<EditRow>; |
| 267 | onApply: (choices: ReadonlyArray<EditState>) => void; |
| 268 | onBack: () => void; |
| 269 | }; |
| 270 | |
| 271 | function EditScreen({ |
| 272 | profile, |
| 273 | rows, |
| 274 | onApply, |
| 275 | onBack, |
| 276 | }: EditScreenProps): JSX.Element { |
| 277 | const { cursor, indices, move, cycle } = useCycleNavigation( |
| 278 | rows.map((row) => row.states.length), |
| 279 | ); |
| 280 | const keys = useKeyGlyphs(); |
| 281 | const glyphs = useGlyphs(); |
| 282 | const [unchanged, setUnchanged] = useState(false); |
| 283 | useTerminalInput((input, key) => { |
| 284 | const plain = !key.ctrl && !key.meta; |
| 285 | if (key.escape) return onBack(); |
| 286 | if (key.up || (plain && input === "k")) move(-1); |
| 287 | else if (key.down || (plain && input === "j")) move(1); |
| 288 | else if ((plain && input === " ") || key.right) { |
| 289 | cycle(1); |
| 290 | setUnchanged(false); |
| 291 | } else if (key.left) { |
| 292 | cycle(-1); |
| 293 | setUnchanged(false); |
| 294 | } else if (key.enter) { |
| 295 | if (indices.every((index) => index === 0)) setUnchanged(true); |
| 296 | else onApply(rows.map((row, i) => row.states[indices[i]]!)); |
| 297 | } |
| 298 | }); |
| 299 | if (rows.length === 0) { |
| 300 | return ( |
| 301 | <Stack> |
| 302 | <Text> |
| 303 | <Text bold color={theme.color.accent}> |
| 304 | edit accounts |
| 305 | </Text> |
| 306 | <Text tone="muted"> · {profile}</Text> |
| 307 | </Text> |
| 308 | <Status>No providers are available for this profile.</Status> |
| 309 | <KeyBar keys={[[keys.escape, "back"]]} /> |
| 310 | </Stack> |
| 311 | ); |
| 312 | } |
| 313 | const choices = rows.map((row) => ({ |
| 314 | label: row.provider, |
| 315 | description: row.method ?? "not connected", |
| 316 | states: row.states.map((state) => ({ |
| 317 | value: state, |
| 318 | label: editStateStyle[state].label, |
| 319 | icon: glyphs[editStateStyle[state].icon], |
| 320 | variant: editStateStyle[state].variant, |
| 321 | })), |
| 322 | })); |
| 323 | return ( |
| 324 | <Stack gap={1}> |
| 325 | <Text> |
| 326 | <Text bold color={theme.color.accent}> |
| 327 | edit accounts |
| 328 | </Text> |
| 329 | <Text tone="muted"> · {profile}</Text> |
| 330 | </Text> |
| 331 | <CycleList choices={choices} cursor={cursor} indices={indices} /> |
| 332 | {unchanged ? ( |
| 333 | <Alert variant="warning" title="No changes to apply"> |
| 334 | Press Space to change an account, or Esc to go back. |
| 335 | </Alert> |
| 336 | ) : null} |
| 337 | <KeyBar |
| 338 | keys={[ |
| 339 | [keys.upDown, "navigate"], |
| 340 | [keys.space, "change"], |
| 341 | [keys.enter, "apply"], |
| 342 | [keys.escape, "back"], |
| 343 | ]} |
| 344 | /> |
| 345 | </Stack> |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | // --- main component --------------------------------------------------------- |
| 350 | |
| 351 | type Mode = "normal" | "rename" | "create" | "delete" | "remove"; |
| 352 | |
| 353 | type DashboardControlsProps = { |
| 354 | readonly mode: Mode; |
| 355 | readonly busy: boolean; |
| 356 | readonly flow: Flow | undefined; |
| 357 | readonly entry: DashboardEntry | undefined; |
| 358 | readonly provider: ProfileProviderDisplay | undefined; |
| 359 | readonly keybinds: ReadonlyArray<readonly [string, string]>; |
| 360 | readonly keyGlyphs: ReturnType<typeof useKeyGlyphs>; |
| 361 | readonly store: DashStore; |
| 362 | readonly setMode: (mode: Mode) => void; |
| 363 | }; |
| 364 | |
| 365 | function DashboardControls({ |
| 366 | mode, |
| 367 | busy, |
| 368 | flow, |
| 369 | entry, |
| 370 | provider, |
| 371 | keybinds, |
| 372 | keyGlyphs, |
| 373 | store, |
| 374 | setMode, |
| 375 | }: DashboardControlsProps) { |
| 376 | if (flow?.inline !== true && (busy || flow !== undefined)) return null; |
| 377 | if (mode === "normal") return <KeyBar keys={keybinds} marginTop={0} />; |
| 378 | if (mode === "delete" && entry !== undefined) { |
| 379 | return ( |
| 380 | <InlineConfirm |
| 381 | message={`Delete '${entry.name}' and all its stored credentials?`} |
| 382 | confirmLabel="Delete" |
| 383 | cancelLabel="Cancel" |
| 384 | onSubmit={(confirmed) => { |
| 385 | setMode("normal"); |
| 386 | if (confirmed) store.dispatch({ kind: "delete", name: entry.name }); |
| 387 | }} |
| 388 | onCancel={() => setMode("normal")} |
| 389 | /> |
| 390 | ); |
| 391 | } |
| 392 | if (mode === "remove" && entry !== undefined && provider !== undefined) { |
| 393 | return ( |
| 394 | <InlineConfirm |
| 395 | message={`Remove '${provider.name}' from profile '${entry.name}'?`} |
| 396 | confirmLabel="Remove" |
| 397 | cancelLabel="Cancel" |
| 398 | onSubmit={(confirmed) => { |
| 399 | setMode("normal"); |
| 400 | if (confirmed) { |
| 401 | store.dispatch({ |
| 402 | kind: "edit-apply", |
| 403 | name: entry.name, |
| 404 | add: [], |
| 405 | reconfigure: [], |
| 406 | remove: [provider.name], |
| 407 | }); |
| 408 | } |
| 409 | }} |
| 410 | onCancel={() => setMode("normal")} |
| 411 | /> |
| 412 | ); |
| 413 | } |
| 414 | |
| 415 | const renaming = mode === "rename" && entry !== undefined; |
| 416 | return ( |
| 417 | <PromptFrame |
| 418 | message={renaming ? `rename '${entry.name}' to` : "new profile name"} |
| 419 | layout="inline" |
| 420 | keys={[ |
| 421 | [keyGlyphs.enter, "confirm"], |
| 422 | [keyGlyphs.escape, "cancel"], |
| 423 | ]} |
| 424 | > |
| 425 | <TextField |
| 426 | key={`${mode}-${entry?.name ?? ""}`} |
| 427 | placeholder={renaming ? `${entry.name}-new` : "my-profile"} |
| 428 | onSubmit={(value) => { |
| 429 | const name = value.trim(); |
| 430 | if (name.length === 0) return; |
| 431 | setMode("normal"); |
| 432 | store.dispatch( |
| 433 | renaming |
| 434 | ? { kind: "rename", name: entry.name, newName: name } |
| 435 | : { kind: "create", name }, |
| 436 | ); |
| 437 | }} |
| 438 | onCancel={() => setMode("normal")} |
| 439 | /> |
| 440 | </PromptFrame> |
| 441 | ); |
| 442 | } |
| 443 | |
| 444 | type DashboardProps = { |
| 445 | store: DashStore; |
| 446 | initialSelected: number; |
| 447 | }; |
| 448 | |
| 449 | export function Dashboard({ |
| 450 | store, |
| 451 | initialSelected, |
| 452 | }: DashboardProps): JSX.Element { |
| 453 | const state = useLiveStore(store); |
| 454 | const keyGlyphs = useKeyGlyphs(); |
| 455 | const { rows } = useTerminalSize(); |
| 456 | const [selected, setSelected] = useState(initialSelected); |
| 457 | // -1 is the profile-level slot: no provider is focused and profile actions |
| 458 | // are shown. Up/down cycles through this slot and every connected provider. |
| 459 | const [focusedProvider, setFocusedProvider] = useState(-1); |
| 460 | const [mode, setMode] = useState<Mode>("normal"); |
| 461 | const [screen, setScreen] = useState<"overview" | "edit">("overview"); |
| 462 | const { entries, focus: requestedFocus, flow, busy, notice } = state; |
| 463 | useEffect(() => { |
| 464 | if (requestedFocus === undefined) return; |
| 465 | const focusIndex = entries.findIndex( |
| 466 | (entry) => entry.name === requestedFocus, |
| 467 | ); |
| 468 | store.clearFocus(); |
| 469 | if (focusIndex >= 0) { |
| 470 | setSelected(focusIndex); |
| 471 | } |
| 472 | }, [entries, requestedFocus, store]); |
| 473 | const index = Math.min(Math.max(selected, 0), entries.length - 1); |
| 474 | const entry = entries[index]; |
| 475 | const details = |
| 476 | entry === undefined ? undefined : store.detailsFor(entry.name); |
| 477 | const providers = details?.state === "ready" ? details.providers : []; |
| 478 | const provider = providers[focusedProvider]; |
| 479 | const moveProviderFocus = (delta: -1 | 1) => |
| 480 | setFocusedProvider((current) => { |
| 481 | const slotCount = providers.length + 1; |
| 482 | const position = current + 1; |
| 483 | return ((position + delta + slotCount) % slotCount) - 1; |
| 484 | }); |
| 485 | useEffect(() => setFocusedProvider(-1), [entry?.name]); |
| 486 | |
| 487 | useTerminalInput((input, key) => { |
| 488 | // flow prompts, the edit screen, and the inline TextField/InlineConfirm |
| 489 | // modes own the keyboard |
| 490 | if (flow !== undefined || screen === "edit" || busy) return; |
| 491 | if (mode !== "normal") return; |
| 492 | if (input === "q" || key.escape || (key.ctrl && input === "c")) { |
| 493 | store.dispatch({ kind: "exit" }); |
| 494 | } else if (key.ctrl || key.meta) { |
| 495 | return; |
| 496 | } else if (entry === undefined) { |
| 497 | if (input === "n") setMode("create"); |
| 498 | } else if (key.left) { |
| 499 | setSelected((s) => (s + entries.length - 1) % entries.length); |
| 500 | setFocusedProvider(-1); |
| 501 | } else if (key.right) { |
| 502 | setSelected((s) => (s + 1) % entries.length); |
| 503 | setFocusedProvider(-1); |
| 504 | } else if (key.up && providers.length > 0) { |
| 505 | moveProviderFocus(-1); |
| 506 | } else if (key.down && providers.length > 0) { |
| 507 | moveProviderFocus(1); |
| 508 | } else if (provider === undefined && input === "R" && !entry.isDefault) { |
| 509 | setMode("rename"); |
| 510 | } else if (provider === undefined && input === "d" && !entry.isDefault) { |
| 511 | setMode("delete"); |
| 512 | } else if ( |
| 513 | provider === undefined && |
| 514 | input === "e" && |
| 515 | details?.state === "ready" |
| 516 | ) { |
| 517 | setScreen("edit"); |
| 518 | } else if (provider === undefined && input === "r") { |
| 519 | store.dispatch({ kind: "refresh", name: entry.name }); |
| 520 | } else if (provider === undefined && input === "n") { |
| 521 | setMode("create"); |
| 522 | } else if (input === "e" && provider !== undefined) { |
| 523 | store.dispatch({ |
| 524 | kind: "edit-apply", |
| 525 | name: entry.name, |
| 526 | add: [], |
| 527 | reconfigure: [provider.name], |
| 528 | remove: [], |
| 529 | }); |
| 530 | } else if (input === "r" && provider !== undefined) { |
| 531 | store.dispatch({ |
| 532 | kind: "refresh", |
| 533 | name: entry.name, |
| 534 | provider: provider.name, |
| 535 | }); |
| 536 | } else if (input === "d" && provider !== undefined) { |
| 537 | setMode("remove"); |
| 538 | } |
| 539 | }); |
| 540 | |
| 541 | if (flow !== undefined && !flow.inline) { |
| 542 | return ( |
| 543 | <Text> |
| 544 | <Text bold color={theme.color.accent}> |
| 545 | {flow.kind === "refresh" ? "refresh" : "edit accounts"} |
| 546 | </Text> |
| 547 | <Text tone="muted"> · {flow.name}</Text> |
| 548 | </Text> |
| 549 | ); |
| 550 | } |
| 551 | |
| 552 | if ( |
| 553 | screen === "edit" && |
| 554 | entry !== undefined && |
| 555 | details !== undefined && |
| 556 | details.state === "ready" |
| 557 | ) { |
| 558 | const rows: EditRow[] = [ |
| 559 | ...details.providers.map((provider) => ({ |
| 560 | provider: provider.name, |
| 561 | method: provider.method, |
| 562 | states: ["keep", "reconfigure", "remove"] as const, |
| 563 | })), |
| 564 | ...details.available.map((name) => ({ |
| 565 | provider: name, |
| 566 | method: undefined, |
| 567 | states: ["skip", "add"] as const, |
| 568 | })), |
| 569 | ]; |
| 570 | return ( |
| 571 | <EditScreen |
| 572 | profile={entry.name} |
| 573 | rows={rows} |
| 574 | onBack={() => setScreen("overview")} |
| 575 | onApply={(choices) => { |
| 576 | const pick = (state: EditState) => |
| 577 | rows.flatMap((row, i) => |
| 578 | choices[i] === state ? [row.provider] : [], |
| 579 | ); |
| 580 | const action: ExternalAction = { |
| 581 | kind: "edit-apply", |
| 582 | name: entry.name, |
| 583 | add: pick("add"), |
| 584 | reconfigure: pick("reconfigure"), |
| 585 | remove: pick("remove"), |
| 586 | }; |
| 587 | setScreen("overview"); |
| 588 | if ( |
| 589 | action.add.length + |
| 590 | action.reconfigure.length + |
| 591 | action.remove.length === |
| 592 | 0 |
| 593 | ) { |
| 594 | return; |
| 595 | } |
| 596 | store.dispatch(action); |
| 597 | }} |
| 598 | /> |
| 599 | ); |
| 600 | } |
| 601 | |
| 602 | const annotation = entry !== undefined && entry.isActive ? "active" : ""; |
| 603 | |
| 604 | const keybinds: ReadonlyArray<readonly [string, string]> = |
| 605 | entry === undefined |
| 606 | ? [ |
| 607 | ["n", "new"], |
| 608 | ["q", "quit"], |
| 609 | ] |
| 610 | : [ |
| 611 | [keyGlyphs.leftRight, "switch profile"], |
| 612 | ...(providers.length === 0 |
| 613 | ? [] |
| 614 | : ([[keyGlyphs.upDown, "focus provider"]] as const)), |
| 615 | ...(provider !== undefined |
| 616 | ? ([ |
| 617 | ["e", "reconfigure"], |
| 618 | ["r", "refresh"], |
| 619 | ["d", "remove"], |
| 620 | ] as const) |
| 621 | : ([ |
| 622 | ...(details?.state === "ready" |
| 623 | ? ([ |
| 624 | ["e", "edit"], |
| 625 | ["r", "refresh"], |
| 626 | ] as const) |
| 627 | : []), |
| 628 | ["n", "new"], |
| 629 | ...(entry.isDefault |
| 630 | ? [] |
| 631 | : ([ |
| 632 | ["R", "rename"], |
| 633 | ["d", "delete"], |
| 634 | ] as const)), |
| 635 | ] as ReadonlyArray<readonly [string, string]>)), |
| 636 | ["q", "quit"], |
| 637 | ]; |
| 638 | |
| 639 | // The frame never exceeds the terminal: the provider list is the only part |
| 640 | // allowed to shrink (a `VirtualList` windows it to whatever rows are left), |
| 641 | // so every other row of chrome opts out of shrinking. Short lists keep the |
| 642 | // compact layout because the cap is a maximum, not a fixed height. |
| 643 | return ( |
| 644 | <Stack maxHeight={rows}> |
| 645 | <Stack flexShrink={0}> |
| 646 | <Tabs |
| 647 | tabs={entries.map((e) => ({ |
| 648 | id: e.name, |
| 649 | label: e.name, |
| 650 | marked: e.isActive, |
| 651 | }))} |
| 652 | active={entry?.name ?? ""} |
| 653 | /> |
| 654 | </Stack> |
| 655 | <Stack gap={1} minHeight={0}> |
| 656 | {entry === undefined ? ( |
| 657 | <Text tone="muted">No profiles yet — press n to create one.</Text> |
| 658 | ) : ( |
| 659 | <> |
| 660 | {/* The profile row is the first focus slot. It sits in the same |
| 661 | gutter as the provider rows and shares their cursor column, so |
| 662 | the pointer moves in a straight line as focus travels. */} |
| 663 | <Box flexShrink={0}> |
| 664 | <Gutter> |
| 665 | <Box flexDirection="row"> |
| 666 | <Pointer focused={provider === undefined} /> |
| 667 | <Text> </Text> |
| 668 | <Text |
| 669 | bold |
| 670 | color={ |
| 671 | provider === undefined ? theme.paint.focus : undefined |
| 672 | } |
| 673 | > |
| 674 | {entry.name} |
| 675 | </Text> |
| 676 | {annotation === "" ? null : ( |
| 677 | <Text tone="muted"> · {annotation}</Text> |
| 678 | )} |
| 679 | </Box> |
| 680 | </Gutter> |
| 681 | </Box> |
| 682 | <Box flexDirection="column" minHeight={0}> |
| 683 | <DetailsPane |
| 684 | details={details ?? { state: "loading" }} |
| 685 | focusedIndex={focusedProvider} |
| 686 | refreshingProvider={ |
| 687 | flow?.kind === "refresh" ? flow.provider : undefined |
| 688 | } |
| 689 | /> |
| 690 | </Box> |
| 691 | </> |
| 692 | )} |
| 693 | </Stack> |
| 694 | {/* Keep one stable status row so notices do not push the controls around. |
| 695 | It shares the gutter with the profile/provider rows above it. */} |
| 696 | <Box minHeight={1} flexShrink={0} paddingLeft={theme.space.indent}> |
| 697 | {busy && flow === undefined ? ( |
| 698 | <Spinner label="working…" /> |
| 699 | ) : notice !== undefined ? ( |
| 700 | <Toast variant={notice.ok ? "info" : "error"}>{notice.message}</Toast> |
| 701 | ) : null} |
| 702 | </Box> |
| 703 | <Stack flexShrink={0}> |
| 704 | <DashboardControls |
| 705 | mode={mode} |
| 706 | busy={busy} |
| 707 | flow={flow} |
| 708 | entry={entry} |
| 709 | provider={provider} |
| 710 | keybinds={keybinds} |
| 711 | keyGlyphs={keyGlyphs} |
| 712 | store={store} |
| 713 | setMode={setMode} |
| 714 | /> |
| 715 | </Stack> |
| 716 | </Stack> |
| 717 | ); |
| 718 | } |
| 719 | |
| 720 | // --- session driver --------------------------------------------------------- |
| 721 | |
| 722 | export interface DashboardSessionOptions<R> { |
| 723 | readonly entries: ReadonlyArray<DashboardEntry>; |
| 724 | readonly selected: string | undefined; |
| 725 | /** |
| 726 | * Resolves a profile's provider details. A typed failure renders as the |
| 727 | * detail pane's `failed` state, showing the error's message. |
| 728 | */ |
| 729 | readonly loadDetails: ( |
| 730 | name: string, |
| 731 | ) => Effect.Effect<ProfileDetailsPayload, { readonly message: string }, R>; |
| 732 | /** Executes a pure store action and returns the refreshed state. */ |
| 733 | readonly execute: ( |
| 734 | action: PureAction, |
| 735 | ) => Effect.Effect<ExecuteResult, never, R>; |
| 736 | /** |
| 737 | * Runs an edit/refresh flow. Its prompts render inside the dashboard via |
| 738 | * the embedded session; resolves with a toast outcome — `ok: false` |
| 739 | * renders as a persistent error notice instead of an auto-dismissing |
| 740 | * success toast. |
| 741 | */ |
| 742 | readonly runFlow: ( |
| 743 | action: FlowAction, |
| 744 | events: { |
| 745 | readonly onProviderStart: (provider: string) => Effect.Effect<void>; |
| 746 | }, |
| 747 | ) => Effect.Effect<{ ok: boolean; message: string }, never, R>; |
| 748 | /** Re-reads entries after a flow (the active profile may have changed). */ |
| 749 | readonly reloadEntries: Effect.Effect< |
| 750 | ReadonlyArray<DashboardEntry>, |
| 751 | never, |
| 752 | R |
| 753 | >; |
| 754 | } |
| 755 | |
| 756 | /** |
| 757 | * Runs the dashboard inside CliKit's application renderer. Pure actions and |
| 758 | * edit/refresh flows share the same frame, and the application clears it on |
| 759 | * exit. |
| 760 | */ |
| 761 | export const runProfileDashboardSession = <R,>( |
| 762 | options: DashboardSessionOptions<R>, |
| 763 | ): Effect.Effect<void, NonInteractiveTerminal, R | CliKit> => |
| 764 | Effect.flatMap(CliKit, (cli) => |
| 765 | cli.application( |
| 766 | // live.open is Scope-bound; the session scope is its release backstop |
| 767 | // (the ensuring(live.close) below settles it on the normal path). |
| 768 | Effect.scoped( |
| 769 | Effect.gen(function* () { |
| 770 | const store = new DashStore(options.entries); |
| 771 | let noticeFiber: Fiber.Fiber<void, never> | undefined; |
| 772 | |
| 773 | const applyResult = Effect.fn(function* (result: ExecuteResult) { |
| 774 | if (noticeFiber !== undefined) yield* Fiber.interrupt(noticeFiber); |
| 775 | store.applyResult(result); |
| 776 | if (result.ok) { |
| 777 | noticeFiber = yield* Effect.sleep("4 seconds").pipe( |
| 778 | Effect.andThen(Effect.sync(() => store.clearNotice())), |
| 779 | Effect.forkChild, |
| 780 | ); |
| 781 | } |
| 782 | }); |
| 783 | |
| 784 | const loadInto = (name: string) => |
| 785 | options.loadDetails(name).pipe( |
| 786 | Effect.flatMap((payload) => |
| 787 | Effect.sync(() => |
| 788 | store.setDetails(name, { state: "ready", ...payload }), |
| 789 | ), |
| 790 | ), |
| 791 | Effect.catch((error) => |
| 792 | Effect.sync(() => |
| 793 | store.setDetails(name, { |
| 794 | state: "failed", |
| 795 | message: error.message, |
| 796 | }), |
| 797 | ), |
| 798 | ), |
| 799 | Effect.catchDefect((defect) => |
| 800 | Effect.sync(() => |
| 801 | store.setDetails(name, { |
| 802 | state: "failed", |
| 803 | message: String(defect), |
| 804 | }), |
| 805 | ), |
| 806 | ), |
| 807 | // Provider discovery can build very large Layers. Yield often |
| 808 | // enough for Sigil's 80ms animation clock to keep painting while |
| 809 | // that CPU-heavy Effect graph is evaluated. |
| 810 | Effect.provideService(Scheduler.MaxOpsBeforeYield, 64), |
| 811 | ); |
| 812 | |
| 813 | const initialSelected = Math.max( |
| 814 | 0, |
| 815 | options.entries.findIndex( |
| 816 | (entry) => entry.name === options.selected, |
| 817 | ), |
| 818 | ); |
| 819 | |
| 820 | const live = yield* cli.live.open( |
| 821 | <Dashboard store={store} initialSelected={initialSelected} />, |
| 822 | { placement: "beforeTranscript" }, |
| 823 | ); |
| 824 | |
| 825 | // Mount the spinner before starting stack import/provider builds. |
| 826 | // Forking the loader first allowed synchronous module evaluation to |
| 827 | // delay the dashboard's first frame, making it look fully hung. |
| 828 | const loader = yield* Effect.forEach( |
| 829 | options.entries, |
| 830 | (entry) => loadInto(entry.name), |
| 831 | { concurrency: 2, discard: true }, |
| 832 | ).pipe(Effect.delay("1 millis"), Effect.forkChild); |
| 833 | |
| 834 | yield* Effect.gen(function* () { |
| 835 | while (true) { |
| 836 | const action = yield* Effect.callback< |
| 837 | PureAction | ExternalAction |
| 838 | >((resume) => { |
| 839 | store.bindResolver((action) => resume(Effect.succeed(action))); |
| 840 | }); |
| 841 | switch (action.kind) { |
| 842 | case "exit": |
| 843 | return; |
| 844 | case "refresh": |
| 845 | case "edit-apply": { |
| 846 | store.setFlow({ |
| 847 | kind: action.kind, |
| 848 | name: action.name, |
| 849 | provider: |
| 850 | action.kind === "refresh" ? action.provider : undefined, |
| 851 | // refresh keeps the overview on screen with a spinner; only |
| 852 | // account editing takes over the whole view |
| 853 | inline: action.kind === "refresh", |
| 854 | }); |
| 855 | const flowEffect = options.runFlow(action, { |
| 856 | onProviderStart: (provider) => |
| 857 | Effect.sync(() => |
| 858 | store.setFlow({ |
| 859 | kind: "refresh", |
| 860 | name: action.name, |
| 861 | provider, |
| 862 | inline: true, |
| 863 | }), |
| 864 | ), |
| 865 | }); |
| 866 | // Provider login implementations emit useful progress in |
| 867 | // standalone commands. Inside the dashboard that progress |
| 868 | // belongs in the provider pane, not the transcript. |
| 869 | const quietRefreshCli = { |
| 870 | ...cli, |
| 871 | output: { |
| 872 | ...cli.output, |
| 873 | info: () => Effect.void, |
| 874 | success: () => Effect.void, |
| 875 | }, |
| 876 | } satisfies CliKit["Service"]; |
| 877 | const result = yield* cli.wizard( |
| 878 | action.kind === "refresh" |
| 879 | ? flowEffect.pipe( |
| 880 | Effect.provideService(CliKit, quietRefreshCli), |
| 881 | ) |
| 882 | : flowEffect, |
| 883 | ); |
| 884 | const entries = yield* options.reloadEntries; |
| 885 | if (action.kind === "refresh") { |
| 886 | // Keep the provider-level spinner visible until the new |
| 887 | // expiry/token details have replaced the old ones. |
| 888 | yield* loadInto(action.name); |
| 889 | } else { |
| 890 | store.setDetails(action.name, { state: "loading" }); |
| 891 | yield* loadInto(action.name).pipe(Effect.forkChild); |
| 892 | } |
| 893 | store.setFlow(undefined); |
| 894 | yield* applyResult({ |
| 895 | ok: result.ok, |
| 896 | message: result.message, |
| 897 | entries, |
| 898 | selected: action.name, |
| 899 | }); |
| 900 | break; |
| 901 | } |
| 902 | default: { |
| 903 | const result = yield* options.execute(action); |
| 904 | // a renamed/created profile needs its details (re)resolved |
| 905 | const names = store.detailNames(); |
| 906 | yield* applyResult(result); |
| 907 | yield* Effect.forEach( |
| 908 | result.entries.filter((e) => !names.has(e.name)), |
| 909 | (e) => loadInto(e.name), |
| 910 | { concurrency: 2, discard: true }, |
| 911 | ).pipe(Effect.forkChild); |
| 912 | } |
| 913 | } |
| 914 | } |
| 915 | }).pipe( |
| 916 | Effect.ensuring(Effect.sync(() => store.dispose())), |
| 917 | Effect.ensuring( |
| 918 | Effect.suspend(() => |
| 919 | noticeFiber === undefined |
| 920 | ? Effect.void |
| 921 | : Fiber.interrupt(noticeFiber), |
| 922 | ), |
| 923 | ), |
| 924 | Effect.ensuring(live.close), |
| 925 | Effect.ensuring(Fiber.interrupt(loader)), |
| 926 | ); |
| 927 | }), |
| 928 | ), |
| 929 | ), |
| 930 | ); |