| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import { |
| 3 | Box, |
| 4 | Gutter, |
| 5 | Pointer, |
| 6 | SectionHeading, |
| 7 | Spinner, |
| 8 | Text, |
| 9 | useBorderStyle, |
| 10 | useGlyphs, |
| 11 | } from "../ui/index.ts"; |
| 12 | import type { JSX } from "react"; |
| 13 | import { theme } from "../../CliKit/index.ts"; |
| 14 | |
| 15 | export interface ProfileProviderDisplay { |
| 16 | readonly name: string; |
| 17 | readonly method: string; |
| 18 | readonly status: "ready" | "configured" | "reauth" | "reconfigure" | "error"; |
| 19 | readonly lines: ReadonlyArray<string>; |
| 20 | } |
| 21 | |
| 22 | export interface ProfileListDisplay { |
| 23 | readonly name: string; |
| 24 | readonly active: boolean; |
| 25 | readonly providers: ReadonlyArray<{ |
| 26 | readonly name: string; |
| 27 | readonly method: string; |
| 28 | }>; |
| 29 | } |
| 30 | |
| 31 | /** Provider credential status → glyph + color + label, shared with the dashboard. */ |
| 32 | export const providerStatusStyle = { |
| 33 | ready: { |
| 34 | color: theme.color.success, |
| 35 | glyph: "success", |
| 36 | label: "ready", |
| 37 | }, |
| 38 | configured: { |
| 39 | color: theme.color.warning, |
| 40 | glyph: "warning", |
| 41 | label: "configured", |
| 42 | }, |
| 43 | reauth: { |
| 44 | color: theme.color.warning, |
| 45 | glyph: "refresh", |
| 46 | label: "needs re-login", |
| 47 | }, |
| 48 | reconfigure: { |
| 49 | color: theme.color.warning, |
| 50 | glyph: "edit", |
| 51 | label: "needs setup", |
| 52 | }, |
| 53 | error: { |
| 54 | color: theme.color.danger, |
| 55 | glyph: "error", |
| 56 | label: "error", |
| 57 | }, |
| 58 | } as const; |
| 59 | |
| 60 | /** |
| 61 | * Styling for the account-edit flow's row states, shared between the |
| 62 | * `profile edit` cycle prompt and the dashboard's edit screen. `keep` is |
| 63 | * the neutral state for connected providers, `skip` for unconnected ones. |
| 64 | */ |
| 65 | export const editStateStyle = { |
| 66 | keep: { |
| 67 | icon: "selected", |
| 68 | variant: "success", |
| 69 | label: undefined, |
| 70 | }, |
| 71 | skip: { |
| 72 | icon: "unselected", |
| 73 | variant: "neutral", |
| 74 | label: undefined, |
| 75 | }, |
| 76 | add: { |
| 77 | icon: "add", |
| 78 | variant: "success", |
| 79 | label: "add", |
| 80 | }, |
| 81 | reconfigure: { |
| 82 | icon: "edit", |
| 83 | variant: "warning", |
| 84 | label: "reconfigure", |
| 85 | }, |
| 86 | remove: { |
| 87 | icon: "error", |
| 88 | variant: "error", |
| 89 | label: "remove", |
| 90 | }, |
| 91 | } as const; |
| 92 | |
| 93 | export type EditState = keyof typeof editStateStyle; |
| 94 | |
| 95 | const columnWidth = (cells: ReadonlyArray<string>): number => |
| 96 | Math.max(0, ...cells.map((cell) => cell.length)) + 2; |
| 97 | |
| 98 | /** `cloudflare (oauth) · aws (sso)` with dim methods and separators. */ |
| 99 | type ProviderSummaryProps = { |
| 100 | readonly providers: ReadonlyArray<{ name: string; method: string }>; |
| 101 | }; |
| 102 | |
| 103 | function ProviderSummary({ providers }: ProviderSummaryProps): JSX.Element { |
| 104 | return providers.length === 0 ? ( |
| 105 | <Text tone="muted">no providers</Text> |
| 106 | ) : ( |
| 107 | <Text> |
| 108 | {providers.map((provider, i) => ( |
| 109 | <Text key={provider.name}> |
| 110 | {i === 0 ? null : <Text tone="muted"> · </Text>} |
| 111 | {provider.name} |
| 112 | <Text tone="muted"> ({provider.method})</Text> |
| 113 | </Text> |
| 114 | ))} |
| 115 | </Text> |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | function ProfileList({ |
| 120 | profiles, |
| 121 | }: { |
| 122 | readonly profiles: ReadonlyArray<ProfileListDisplay>; |
| 123 | }): JSX.Element { |
| 124 | const glyphs = useGlyphs(); |
| 125 | const borderStyle = useBorderStyle(); |
| 126 | const nameWidth = columnWidth(profiles.map((profile) => profile.name)); |
| 127 | return ( |
| 128 | <Box flexDirection="column"> |
| 129 | <Box |
| 130 | marginBottom={1} |
| 131 | borderStyle={borderStyle} |
| 132 | borderBottom |
| 133 | borderTop={false} |
| 134 | borderLeft={false} |
| 135 | borderRight={false} |
| 136 | borderColor={theme.color.muted} |
| 137 | borderDimColor |
| 138 | > |
| 139 | <SectionHeading annotation={`${profiles.length}`}> |
| 140 | Profiles |
| 141 | </SectionHeading> |
| 142 | </Box> |
| 143 | {profiles.length === 0 ? ( |
| 144 | <Gutter> |
| 145 | <Text tone="muted"> |
| 146 | {"No profiles configured. Run `alchemy profile` to create one."} |
| 147 | </Text> |
| 148 | </Gutter> |
| 149 | ) : ( |
| 150 | profiles.map((profile) => ( |
| 151 | <Gutter key={profile.name}> |
| 152 | <Box flexDirection="row"> |
| 153 | <Text tone="brand">{profile.active ? glyphs.selected : " "}</Text> |
| 154 | <Text> </Text> |
| 155 | <Box width={nameWidth} flexShrink={0}> |
| 156 | <Text bold={profile.active}>{profile.name}</Text> |
| 157 | </Box> |
| 158 | <ProviderSummary providers={profile.providers} /> |
| 159 | </Box> |
| 160 | </Gutter> |
| 161 | )) |
| 162 | )} |
| 163 | </Box> |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | /** Options every provider block shares; the dashboard threads them through. */ |
| 168 | export interface ProviderBlockOptions { |
| 169 | /** Muted hint appended to rows with `status: "reauth"`. */ |
| 170 | readonly reauthHint?: string; |
| 171 | /** Provider whose detail rows are temporarily replaced by refresh status. */ |
| 172 | readonly refreshingProvider?: string; |
| 173 | /** Provider currently focused by an interactive parent view. */ |
| 174 | readonly focusedProvider?: string; |
| 175 | /** |
| 176 | * Reserve the list-cursor column (see `Pointer`) so an interactive parent |
| 177 | * can mark the focused provider without shifting the table. |
| 178 | */ |
| 179 | readonly focusColumn?: boolean; |
| 180 | } |
| 181 | |
| 182 | /** Column widths computed over every provider so windowed blocks stay aligned. */ |
| 183 | export const providerColumnWidths = ( |
| 184 | providers: ReadonlyArray<ProfileProviderDisplay>, |
| 185 | ): { readonly nameWidth: number; readonly methodWidth: number } => ({ |
| 186 | nameWidth: columnWidth(providers.map((provider) => provider.name)), |
| 187 | methodWidth: columnWidth(providers.map((provider) => provider.method)), |
| 188 | }); |
| 189 | |
| 190 | /** |
| 191 | * Rows a `ProviderBlock` occupies: one blank spacer row above every block |
| 192 | * but the first, the header row, then the detail rows (at least one, so the |
| 193 | * refresh spinner fits inside the same reserved height). The dashboard |
| 194 | * windows providers by this number, so keep it in step with the layout. |
| 195 | */ |
| 196 | export const providerBlockHeight = ( |
| 197 | provider: ProfileProviderDisplay, |
| 198 | first: boolean, |
| 199 | ): number => (first ? 0 : 1) + 1 + Math.max(provider.lines.length, 1); |
| 200 | |
| 201 | /** |
| 202 | * Detail lines arrive as `key: value` strings (plus free-form diagnostic |
| 203 | * messages). Split the former so keys render as a muted, aligned column and |
| 204 | * values start together; anything that does not fit the shape is shown as-is. |
| 205 | */ |
| 206 | const splitDetail = ( |
| 207 | line: string, |
| 208 | ): { readonly key: string; readonly value: string } | undefined => { |
| 209 | const match = /^([A-Za-z0-9_.-]+): (.*)$/.exec(line); |
| 210 | return match === null ? undefined : { key: match[1]!, value: match[2]! }; |
| 211 | }; |
| 212 | |
| 213 | /** |
| 214 | * One provider in the table: `[cursor] name method status` on the header |
| 215 | * row, its details indented beneath. Blocks are separated by a blank row |
| 216 | * rather than a rule, and the focused block is marked by the cursor glyph and |
| 217 | * a brand-coloured name — never by a rail. |
| 218 | */ |
| 219 | export function ProviderBlock({ |
| 220 | provider, |
| 221 | first, |
| 222 | nameWidth, |
| 223 | methodWidth, |
| 224 | reauthHint, |
| 225 | refreshingProvider, |
| 226 | focusedProvider, |
| 227 | focusColumn = false, |
| 228 | }: ProviderBlockOptions & { |
| 229 | readonly provider: ProfileProviderDisplay; |
| 230 | readonly first: boolean; |
| 231 | readonly nameWidth: number; |
| 232 | readonly methodWidth: number; |
| 233 | }): JSX.Element { |
| 234 | const glyphs = useGlyphs(); |
| 235 | const status = providerStatusStyle[provider.status]; |
| 236 | const focused = focusColumn && provider.name === focusedProvider; |
| 237 | const details = provider.lines.map((line) => ({ |
| 238 | line, |
| 239 | parts: splitDetail(line), |
| 240 | })); |
| 241 | const keyWidth = columnWidth( |
| 242 | details.flatMap(({ parts }) => (parts === undefined ? [] : [parts.key])), |
| 243 | ); |
| 244 | // Details sit under the name, past the cursor column when there is one. |
| 245 | const detailIndent = (focusColumn ? 2 : 0) + theme.space.indent; |
| 246 | return ( |
| 247 | <Box flexDirection="column" paddingTop={first ? 0 : 1}> |
| 248 | <Gutter> |
| 249 | <Box flexDirection="row"> |
| 250 | {focusColumn ? ( |
| 251 | <> |
| 252 | <Pointer focused={focused} /> |
| 253 | <Text> </Text> |
| 254 | </> |
| 255 | ) : null} |
| 256 | <Box width={nameWidth} flexShrink={0}> |
| 257 | <Text bold color={focused ? theme.paint.focus : undefined}> |
| 258 | {provider.name} |
| 259 | </Text> |
| 260 | </Box> |
| 261 | <Box width={methodWidth} flexShrink={0}> |
| 262 | <Text tone="muted">{provider.method}</Text> |
| 263 | </Box> |
| 264 | <Text color={status.color}> |
| 265 | {glyphs[status.glyph]} {status.label} |
| 266 | </Text> |
| 267 | {reauthHint !== undefined && |
| 268 | provider.status === "reauth" && |
| 269 | (focusedProvider === undefined || |
| 270 | provider.name === focusedProvider) ? ( |
| 271 | <Text tone="muted"> — {reauthHint}</Text> |
| 272 | ) : null} |
| 273 | </Box> |
| 274 | </Gutter> |
| 275 | <Box |
| 276 | flexDirection="column" |
| 277 | minHeight={Math.max(provider.lines.length, 1)} |
| 278 | > |
| 279 | {provider.name === refreshingProvider ? ( |
| 280 | <Gutter> |
| 281 | <Box paddingLeft={detailIndent}> |
| 282 | <Spinner |
| 283 | label={`refreshing ${provider.method.toLowerCase() === "oauth" ? "OAuth" : provider.method} credentials…`} |
| 284 | /> |
| 285 | </Box> |
| 286 | </Gutter> |
| 287 | ) : ( |
| 288 | details.map(({ line, parts }, lineIndex) => ( |
| 289 | <Gutter key={`${provider.name}-${lineIndex}`}> |
| 290 | <Box paddingLeft={detailIndent}> |
| 291 | {parts === undefined ? ( |
| 292 | <Text>{line}</Text> |
| 293 | ) : ( |
| 294 | <> |
| 295 | <Box width={keyWidth} flexShrink={0}> |
| 296 | <Text tone="muted">{parts.key}</Text> |
| 297 | </Box> |
| 298 | <Text>{parts.value}</Text> |
| 299 | </> |
| 300 | )} |
| 301 | </Box> |
| 302 | </Gutter> |
| 303 | )) |
| 304 | )} |
| 305 | </Box> |
| 306 | </Box> |
| 307 | ); |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Provider table body shared by `profile show` and the dashboard's detail |
| 312 | * pane, so the two render identically. The dashboard passes `reauthHint` to |
| 313 | * advertise its `r` keybinding on rows that need a re-login. |
| 314 | */ |
| 315 | export function ProfileDetailsBody({ |
| 316 | providers, |
| 317 | ...options |
| 318 | }: ProviderBlockOptions & { |
| 319 | readonly providers: ReadonlyArray<ProfileProviderDisplay>; |
| 320 | }): JSX.Element { |
| 321 | const { nameWidth, methodWidth } = providerColumnWidths(providers); |
| 322 | return ( |
| 323 | <Box flexDirection="column"> |
| 324 | {providers.length === 0 ? ( |
| 325 | <Gutter> |
| 326 | <Text tone="muted">No providers configured.</Text> |
| 327 | </Gutter> |
| 328 | ) : ( |
| 329 | providers.map((provider, index) => ( |
| 330 | <ProviderBlock |
| 331 | key={provider.name} |
| 332 | provider={provider} |
| 333 | first={index === 0} |
| 334 | nameWidth={nameWidth} |
| 335 | methodWidth={methodWidth} |
| 336 | {...options} |
| 337 | /> |
| 338 | )) |
| 339 | )} |
| 340 | </Box> |
| 341 | ); |
| 342 | } |
| 343 | |
| 344 | function ProfileDetails({ |
| 345 | profile, |
| 346 | providers, |
| 347 | active, |
| 348 | }: { |
| 349 | readonly profile: string; |
| 350 | readonly providers: ReadonlyArray<ProfileProviderDisplay>; |
| 351 | readonly active: boolean; |
| 352 | }): JSX.Element { |
| 353 | const borderStyle = useBorderStyle(); |
| 354 | return ( |
| 355 | <Box flexDirection="column"> |
| 356 | <Box |
| 357 | marginBottom={1} |
| 358 | borderStyle={borderStyle} |
| 359 | borderBottom |
| 360 | borderTop={false} |
| 361 | borderLeft={false} |
| 362 | borderRight={false} |
| 363 | borderColor={theme.color.muted} |
| 364 | borderDimColor |
| 365 | > |
| 366 | <SectionHeading annotation={active ? "active" : undefined}> |
| 367 | Profile {profile} |
| 368 | </SectionHeading> |
| 369 | </Box> |
| 370 | <ProfileDetailsBody providers={providers} /> |
| 371 | </Box> |
| 372 | ); |
| 373 | } |
| 374 | |
| 375 | function ProfileNotice({ |
| 376 | profile, |
| 377 | message, |
| 378 | }: { |
| 379 | readonly profile: string; |
| 380 | readonly message: string; |
| 381 | }): JSX.Element { |
| 382 | const glyphs = useGlyphs(); |
| 383 | const borderStyle = useBorderStyle(); |
| 384 | return ( |
| 385 | <Box flexDirection="column"> |
| 386 | <Box |
| 387 | marginBottom={1} |
| 388 | borderStyle={borderStyle} |
| 389 | borderBottom |
| 390 | borderTop={false} |
| 391 | borderLeft={false} |
| 392 | borderRight={false} |
| 393 | borderColor={theme.color.muted} |
| 394 | borderDimColor |
| 395 | > |
| 396 | <SectionHeading>Profile {profile}</SectionHeading> |
| 397 | </Box> |
| 398 | <Gutter> |
| 399 | <Text color={theme.color.warning}> |
| 400 | {glyphs.warning} {message} |
| 401 | </Text> |
| 402 | </Gutter> |
| 403 | </Box> |
| 404 | ); |
| 405 | } |
| 406 | |
| 407 | function CurrentProfile({ |
| 408 | name, |
| 409 | source, |
| 410 | }: { |
| 411 | readonly name: string; |
| 412 | readonly source: string; |
| 413 | }): JSX.Element { |
| 414 | const glyphs = useGlyphs(); |
| 415 | return ( |
| 416 | <Text> |
| 417 | <Text tone="brand">{glyphs.selected}</Text> <Text bold>{name}</Text>{" "} |
| 418 | <Text tone="muted">({source})</Text> |
| 419 | </Text> |
| 420 | ); |
| 421 | } |
| 422 | |
| 423 | /** |
| 424 | * View builders consumed by `CliKit.print` and the interactive profile app. |
| 425 | */ |
| 426 | export const profileListNode = ( |
| 427 | profiles: ReadonlyArray<ProfileListDisplay>, |
| 428 | ): JSX.Element => <ProfileList profiles={profiles} />; |
| 429 | |
| 430 | export const profileDetailsNode = ( |
| 431 | profile: string, |
| 432 | providers: ReadonlyArray<ProfileProviderDisplay>, |
| 433 | active: boolean, |
| 434 | ): JSX.Element => ( |
| 435 | <ProfileDetails profile={profile} providers={providers} active={active} /> |
| 436 | ); |
| 437 | |
| 438 | export const profileNoticeNode = ( |
| 439 | profile: string, |
| 440 | message: string, |
| 441 | ): JSX.Element => <ProfileNotice profile={profile} message={message} />; |
| 442 | |
| 443 | export const currentProfileNode = ( |
| 444 | name: string, |
| 445 | source: string, |
| 446 | ): JSX.Element => <CurrentProfile name={name} source={source} />; |