Skip to content
Source
Sign inSign up
Open History to see the last commit

WorktreeSetupCard.tsx

394 lines12.6 KB

1import {
2 worktreeSetupStageLabel,
3 type WorktreeSetupSnapshot,
4 type WorktreeSetupStage,
5} from "@t3tools/contracts";
6import { formatDuration } from "@t3tools/shared/orchestrationTiming";
7import {
8 CheckIcon,
9 CircleAlertIcon,
10 CircleIcon,
11 LaptopIcon,
12 MinusIcon,
13 TerminalIcon,
14 XIcon,
15 ChevronDownIcon,
16 ChevronRightIcon,
17} from "lucide-react";
18import { useState, type ReactNode } from "react";
19​
20import { Button } from "~/components/ui/button";
21import { Spinner } from "~/components/ui/spinner";
22import { MiddleTruncate } from "../ui/middle-truncate";
23import { observeVisibleAnimation } from "~/lib/visibleAnimation";
24import { cn } from "~/lib/utils";
25import { useNowSecond } from "~/hooks/useNowSecond";
26import { WorkLogRow } from "./WorkLog";
27​
28interface WorktreeSetupCardProps {
29 snapshot: WorktreeSetupSnapshot;
30 onCancel: (() => void) | null;
31 onWorkLocally: (() => void) | null;
32 onOpenTerminal: (() => void) | null;
34​
35function elapsedMs(startedAt: string | null, endMs: number): number | null {
36 if (!startedAt) return null;
37 const start = Date.parse(startedAt);
38 if (!Number.isFinite(start) || !Number.isFinite(endMs)) return null;
39 return Math.max(0, endMs - start);
41​
42/** Mounted only while setup runs, so finished setups never subscribe to the tick. */
43function LiveElapsed({ startedAt }: { startedAt: string }) {
44 const elapsed = elapsedMs(startedAt, useNowSecond());
45 return elapsed === null ? null : <>{formatDuration(elapsed)}</>;
47​
48/** A finished duration, or a ticking one while setup is still running. */
49function Elapsed({
50 startedAt,
51 endedAt,
52 live,
53}: {
54 startedAt: string | null;
55 endedAt: string | null;
56 live: boolean;
57}) {
58 if (!startedAt) return null;
59 if (endedAt) {
60 const elapsed = elapsedMs(startedAt, Date.parse(endedAt));
61 return elapsed === null ? null : <>{formatDuration(elapsed)}</>;
62 }
63 return live ? <LiveElapsed startedAt={startedAt} /> : null;
65​
66function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {
67 const className = "size-4 shrink-0 stroke-2";
68 switch (status) {
69 case "done":
70 return <CheckIcon aria-hidden className={className} />;
71 case "running":
72 return <Spinner size="md" className="shrink-0" />;
73 case "failed":
74 return <XIcon aria-hidden className={className} />;
75 case "warning":
76 return <CircleAlertIcon aria-hidden className={className} />;
77 case "skipped":
78 return <MinusIcon aria-hidden className={className} />;
79 case "pending":
80 return <CircleIcon aria-hidden className={className} />;
81 }
83​
84function stageRowClassName(status: WorktreeSetupStage["status"]): string {
85 switch (status) {
86 case "failed":
87 return "text-destructive-foreground";
88 case "warning":
89 return "text-warning-foreground";
90 case "pending":
91 return "text-secondary-label opacity-40";
92 case "running":
93 case "skipped":
94 case "done":
95 return "text-secondary-label";
96 }
98​
99function ShimmerOverlay({ children }: { children: ReactNode }) {
100 return (
101 <span
102 aria-hidden
103 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
104 >
105 <span className="live-activity-focus-counter block">
106 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
107 </span>
108 </span>
109 );
111​
112function headerLabel(snapshot: WorktreeSetupSnapshot): string {
113 switch (snapshot.phase) {
114 case "running":
115 return "Setting up worktree…";
116 case "done":
117 return snapshot.stages.some((stage) => stage.status === "failed")
118 ? "Worktree ready, setup script failed"
119 : "Worktree ready";
120 case "failed":
121 return "Worktree setup failed";
122 case "cancelled":
123 return "Worktree setup cancelled";
124 }
126​
127function SetupHeaderRow({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
128 const running = snapshot.phase === "running";
129 const failed = snapshot.phase === "failed";
130 const finishedWithFailedStage =
131 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");
132 const text = headerLabel(snapshot);
133 const tone = failed
134 ? "text-destructive-foreground"
135 : finishedWithFailedStage
136 ? "text-warning-foreground"
137 : "text-muted-foreground";
138 return (
139 <div className="border-b border-border/60 pb-2 pt-1">
140 <div
141 className={cn(
142 "flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed tabular-nums",
143 tone,
144 )}
145 >
146 <span
147 ref={running ? observeVisibleAnimation : undefined}
148 className="relative min-w-0 shrink overflow-hidden whitespace-nowrap"
149 >
150 <span className="block truncate">{text}</span>
151 {running ? <ShimmerOverlay>{text}</ShimmerOverlay> : null}
152 </span>
153 <span className="ml-auto shrink-0 text-xs text-muted-foreground empty:hidden">
154 <Elapsed startedAt={snapshot.startedAt} endedAt={snapshot.endedAt} live={running} />
155 </span>
156 </div>
157 </div>
158 );
160​
161function StageRow({
162 stage,
163 setupRunning,
164 setupEndedAt,
165 scriptName,
166}: {
167 stage: WorktreeSetupStage;
168 setupRunning: boolean;
169 setupEndedAt: string | null;
170 scriptName: string | null;
171}) {
172 const label =
173 stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id);
174 const running = stage.status === "running";
175 const trailing =
176 stage.status === "pending"
177 ? null
178 : stage.status === "skipped"
179 ? (stage.detail ?? "skipped")
180 : stage.id === "checkout" && running && stage.percent !== null
181 ? `${stage.percent}%`
182 : stage.detail;
183 return (
184 <WorkLogRow
185 data-worktree-setup-stage={stage.id}
186 data-worktree-setup-status={stage.status}
187 icon={
188 <span className={cn("text-icon-muted", stage.status === "pending" && "opacity-40")}>
189 <StageIcon status={stage.status} />
190 </span>
191 }
192 label={
193 <span
194 ref={running ? observeVisibleAnimation : undefined}
195 className={cn("relative block truncate", stageRowClassName(stage.status))}
196 >
197 {label}
198 {running ? <ShimmerOverlay>{label}</ShimmerOverlay> : null}
199 </span>
200 }
201 trailing={
202 <>
203 {trailing ? (
204 <span className="max-w-1/2 min-w-0 truncate text-xs text-muted-foreground tabular-nums">
205 {trailing}
206 </span>
207 ) : null}
208 {stage.status !== "skipped" && stage.status !== "pending" ? (
209 <span className="shrink-0 text-xs text-muted-foreground tabular-nums empty:hidden">
210 <Elapsed
211 startedAt={stage.startedAt}
212 // A stage interrupted by the setup ending stops with it.
213 endedAt={stage.endedAt ?? setupEndedAt}
214 live={setupRunning}
215 />
216 </span>
217 ) : null}
218 </>
219 }
220 />
221 );
223​
224const OUTPUT_TAIL_LINES = 4;
225const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot);
226​
227function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {
228 const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({
229 slot,
230 line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "",
231 }));
232 return (
233 <pre
234 className={cn(
235 "mb-1 ml-8 overflow-hidden rounded-md border px-2.5 py-1.5 font-mono text-2xs leading-relaxed select-text",
236 failed
237 ? "border-destructive/20 bg-error-surface text-destructive-foreground"
238 : "border-border bg-code text-muted-foreground",
239 )}
240 >
241 {rows.map(({ slot, line }) => (
242 <div key={slot} className="truncate whitespace-pre">
243 {line.length === 0 ? "\u00a0" : line}
244 </div>
245 ))}
246 </pre>
247 );
249​
250function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
251 return (
252 <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
253 {snapshot.branch ? (
254 <>
255 <dt className="text-foreground/80">Branch</dt>
256 <dd className="min-w-0 font-mono">
257 <MiddleTruncate value={snapshot.branch} className="flex" />
258 </dd>
259 </>
260 ) : null}
261 {snapshot.baseRef ? (
262 <>
263 <dt className="text-foreground/80">Base</dt>
264 <dd className="min-w-0 font-mono">
265 <MiddleTruncate value={snapshot.baseRef} className="flex" />
266 </dd>
267 </>
268 ) : null}
269 {snapshot.worktreePath ? (
270 <>
271 <dt className="text-foreground/80">Path</dt>
272 <dd className="min-w-0 font-mono">
273 <MiddleTruncate value={snapshot.worktreePath} className="flex" />
274 </dd>
275 </>
276 ) : null}
277 {snapshot.setupScript ? (
278 <>
279 <dt className="text-foreground/80">Setup</dt>
280 <dd className="truncate font-mono">{snapshot.setupScript.command}</dd>
281 </>
282 ) : null}
283 </dl>
284 );
286​
287function CollapsedSummaryRow({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
288 const status: WorktreeSetupStage["status"] =
289 snapshot.phase === "failed" || snapshot.phase === "cancelled"
290 ? "failed"
291 : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed")
292 ? "failed"
293 : "done";
294 const label = headerLabel(snapshot);
295 return (
296 <WorkLogRow
297 data-worktree-setup-stage="summary"
298 data-worktree-setup-status={status}
299 icon={
300 <span className="text-icon-muted">
301 <StageIcon status={status} />
302 </span>
303 }
304 label={<span className={stageRowClassName(status)}>{label}</span>}
305 trailing={
306 <span className="shrink-0 text-xs text-muted-foreground tabular-nums empty:hidden">
307 <Elapsed startedAt={snapshot.startedAt} endedAt={snapshot.endedAt} live={false} />
308 </span>
309 }
310 />
311 );
313​
314export function WorktreeSetupCard({
315 snapshot,
316 onCancel,
317 onWorkLocally,
318 onOpenTerminal,
319 embedded = false,
320}: WorktreeSetupCardProps & {
321 embedded?: boolean;
322}) {
323 const running = snapshot.phase === "running";
324 const [detailsOpen, setDetailsOpen] = useState(false);
325 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
326 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
327 const collapsed = embedded && !running;
328 const showHeader = !embedded && !running && snapshot.phase !== "done";
329 const showTail =
330 setupStage !== undefined &&
331 (setupStage.status === "failed" ||
332 (setupStage.status === "running" && setupStage.tail.length > 0));
333​
334 return (
335 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
336 {showHeader ? <SetupHeaderRow snapshot={snapshot} /> : null}
337 {collapsed ? (
338 <CollapsedSummaryRow snapshot={snapshot} />
339 ) : (
340 <div className={showHeader ? "pt-1.5" : undefined}>
341 {snapshot.stages.map((stage) => (
342 <div key={stage.id}>
343 <StageRow
344 stage={stage}
345 setupRunning={running}
346 setupEndedAt={snapshot.endedAt}
347 scriptName={snapshot.setupScript?.name ?? null}
348 />
349 {stage.id === "setup-script" && showTail ? (
350 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
351 ) : null}
352 </div>
353 ))}
354 </div>
355 )}
356​
357 {snapshot.phase === "failed" && snapshot.error ? (
358 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>
359 ) : null}
360​
361 <div className="mt-0.5 ml-[calc(--spacing(6)+2px-(--spacing(2)-1px))] flex flex-wrap items-center gap-0.5">
362 <Button
363 type="button"
364 size="xs"
365 variant="ghost-muted"
366 aria-expanded={detailsOpen}
367 onClick={() => setDetailsOpen((open) => !open)}
368 >
369 {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />}
370 Details
371 </Button>
372 {showTerminal ? (
373 <Button type="button" size="xs" variant="ghost-muted" onClick={onOpenTerminal}>
374 <TerminalIcon aria-hidden />
375 Open terminal
376 </Button>
377 ) : null}
378 {onWorkLocally ? (
379 <Button type="button" size="xs" variant="ghost-muted" onClick={onWorkLocally}>
380 <LaptopIcon aria-hidden />
381 Work locally
382 </Button>
383 ) : null}
384 {onCancel && running ? (
385 <Button type="button" size="xs" variant="ghost-muted" onClick={onCancel}>
386 <XIcon aria-hidden />
387 Cancel
388 </Button>
389 ) : null}
390 </div>
391 {detailsOpen ? <SetupDetails snapshot={snapshot} /> : null}
392 </section>
393 );