1/** @jsxImportSource @alchemy.run/sigil */
2import {
3 Box,
4 Gutter,
5 Pointer,
6 SectionHeading,
7 Spinner,
8 Text,
9 useBorderStyle,
10 useGlyphs,
11} from "../ui/index.ts";
12import type { JSX } from "react";
13import { theme } from "../../CliKit/index.ts";
14
15export 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
22export 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. */
32export 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 */
65export 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
93export type EditState = keyof typeof editStateStyle;
94
95const 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. */
99type ProviderSummaryProps = {
100 readonly providers: ReadonlyArray<{ name: string; method: string }>;
101};
102
103function 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
119function 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. */
168export 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. */
183export 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 */
196export 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 */
206const 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 */
219export 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 */
315export 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
344function 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
375function 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
407function 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 */
426export const profileListNode = (
427 profiles: ReadonlyArray<ProfileListDisplay>,
428): JSX.Element => <ProfileList profiles={profiles} />;
429
430export 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
438export const profileNoticeNode = (
439 profile: string,
440 message: string,
441): JSX.Element => <ProfileNotice profile={profile} message={message} />;
442
443export const currentProfileNode = (
444 name: string,
445 source: string,
446): JSX.Element => <CurrentProfile name={name} source={source} />;