Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/collisions.tsx
Snapshot at 34e3175
1import { WriteHint } from "./freshness.tsx";
2import { useEffect, useId, useRef, useState, type ReactNode } from "react";
3import { Badge, Banner, Button, Dialog, Textarea, Tooltip } from "@cloudflare/kumo";
4import { Warning } from "@phosphor-icons/react";
5import type { BlockerView, WireBlocker } from "./contracts.ts";
6import { blockedLabel, blockerPacket, collisionHistory } from "./model.ts";
7import { relativeTime } from "./time.ts";
8import { ApiError } from "./transport.ts";
9
10export function CollisionSentence({ blocker: b }: { blocker: BlockerView }) {
11 return (
12 <span className="collision-copy">
13 {b.pushingAgent ?? b.agent} pushed <code title={b.sha}>{b.sha?.slice(0, 7)}</code> to{" "}
14 <span className="path-summary">
15 {b.paths[0] && (
16 <Tooltip content={b.paths[0]} render={<code className="truncated-code" />}>
17 {b.paths[0]}
18 </Tooltip>
19 )}
20 <span className="path-more">{b.paths.length > 1 && <>+{b.paths.length - 1}</>},</span>
21 </span>{" "}
22 which {b.claimingAgent} has claimed for {b.packet}.
23 </span>
24 );
25}
26
27export function MarkResolved({
28 blocker,
29 owner,
30 open,
31 disabled,
32 disabledReason,
33}: {
34 blocker: BlockerView;
35 owner: boolean;
36 open?: (blocker: BlockerView) => void;
37 disabled?: boolean;
38 disabledReason?: string;
39}) {
40 if (!owner || !open || blocker.resolved) return null;
41 return (
42 <WriteHint reason={disabledReason}><Button
43 variant="secondary"
44 size="sm"
45 disabled={disabled}
46 aria-label={`Mark resolved: collision on ${blocker.packet}`}
47 onClick={() => { if (!disabled) open(blocker); }}
48 >
49 Mark resolved
50 </Button></WriteHint>
51 );
52}
53
54export function CollisionBanner({
55 blocker,
56 owner,
57 open,
58 showPacket,
59 disabled,
60 disabledReason,
61}: {
62 blocker: BlockerView;
63 owner: boolean;
64 open?: (blocker: BlockerView) => void;
65 showPacket(id: string): void;
66 disabled?: boolean;
67 disabledReason?: string;
68}) {
69 return (
70 <Banner
71 className="collision-banner"
72 variant="alert"
73 description={
74 <>
75 <CollisionSentence blocker={blocker} /> {blockedLabel(blocker)} is blocked until it's
76 resolved.
77 </>
78 }
79 action={
80 <div className="actions">
81 <Button size="sm" aria-label={`View packet ${blockerPacket(blocker)}`} onClick={() => showPacket(blockerPacket(blocker))}>
82 View packet
83 </Button>
84 <MarkResolved blocker={blocker} owner={owner} open={open} disabled={disabled} disabledReason={disabledReason} />
85 </div>
86 }
87 />
88 );
89}
90
91export function CollisionRows({
92 blockers,
93 owner,
94 open,
95 disabled,
96 disabledReason,
97 highlighted,
98 reviewLink,
99}: {
100 blockers: BlockerView[];
101 owner: boolean;
102 open?: (blocker: BlockerView) => void;
103 disabled?: boolean;
104 disabledReason?: string;
105 highlighted?: string;
106 reviewLink?(blocker: BlockerView): ReactNode;
107}) {
108 const root = useRef<HTMLDivElement>(null);
109 useEffect(() => {
110 if (highlighted)
111 root.current?.querySelector<HTMLButtonElement>(".collision-highlight button")?.focus();
112 }, [highlighted]);
113 const ordered = collisionHistory(blockers);
114 const newestResolved = ordered.find((b) => b.resolved);
115 const focused = ordered.find((b) => b.id === highlighted);
116 return (
117 <div ref={root} className="collision-history">
118 {(focused ? [focused, ...ordered.filter((b) => b !== focused)] : ordered).map((b) => {
119 const resolution = b.resolution;
120 return (
121 <article
122 key={b.id}
123 className={`collision-row ${b.id === highlighted ? "collision-highlight" : ""}`}
124 data-collision={b.id}
125 >
126 <p className="caption">
127 <CollisionSentence blocker={b} />
128 </p>
129 <p className="caption muted">
130 <Badge
131 appearance="dot"
132 variant="neutral"
133 className={`status-dot status-${b.resolved ? "success" : "warning"}`}
134 >
135 {b.resolved ? "Resolved" : "Open"}
136 </Badge>
137 {" · "}
138 {b.resolved && resolution ? (
139 <>
140 {relativeTime(resolution.at)} ·{" "}
141 {resolution.by === "owner"
142 ? owner
143 ? "by you"
144 : "by the owner"
145 : resolution.by === "promote"
146 ? "when a claim ended in a promote"
147 : "when a claim was released"}
148 </>
149 ) : b.at ? (
150 <>since {relativeTime(b.at)}</>
151 ) : (
152 <>
153 pushed <code>{b.sha?.slice(0, 7)}</code>
154 </>
155 )}
156 </p>
157 {resolution?.by === "owner" && resolution.reason !== undefined && (
158 <p className="caption plain-text">“{resolution.reason}”</p>
159 )}
160 {b.id === newestResolved?.id && reviewLink?.(b)}
161 <MarkResolved blocker={b} owner={owner} open={open} disabled={disabled} disabledReason={disabledReason} />
162 </article>
163 );
164 })}
165 </div>
166 );
167}
168
169/** Kumo Dialog supplies focus trapping and Escape cancellation; errors never render raw API copy. */
170export function ResolveCollisionDialog({
171 blocker,
172 resolve,
173 close,
174 disabled,
175 disabledReason,
176}: {
177 disabled?: boolean;
178 disabledReason?: string;
179 blocker: BlockerView;
180 resolve(id: string, reason: string): Promise<WireBlocker>;
181 close(resolved?: boolean): void;
182}) {
183 const id = useId();
184 const firstReason = useRef<HTMLButtonElement>(null);
185 const textarea = useRef<HTMLTextAreaElement>(null);
186 const closeButton = useRef<HTMLButtonElement>(null);
187 const [reason, setReason] = useState("");
188 const [sending, setSending] = useState(false);
189 const [error, setError] = useState<string>();
190 const [missing, setMissing] = useState(false);
191 useEffect(() => {
192 firstReason.current?.focus();
193 }, []);
194 useEffect(() => {
195 if (missing) closeButton.current?.focus();
196 }, [missing]);
197 const submit = async () => {
198 if (disabled || sending || missing || reason.trim().length < 1 || reason.length > 200) return;
199 setSending(true);
200 setError(undefined);
201 try {
202 await resolve(blocker.id, reason);
203 close(true);
204 } catch (e) {
205 const notFound = e instanceof ApiError && e.status === 404;
206 setMissing(notFound);
207 setError(
208 notFound
209 ? "This collision doesn't exist anymore."
210 : "Couldn't mark it resolved. Try again.",
211 );
212 } finally {
213 setSending(false);
214 }
215 };
216 const quickReasons = [
217 "Coordinated: both changes can land.",
218 `${blocker.pushingAgent ?? blocker.agent} will rebase on ${blocker.claimingAgent}'s work before promoting.`,
219 `${blocker.claimingAgent} no longer needs these paths.`,
220 ];
221 return (
222 <Dialog.Root
223 open
224 onOpenChange={(open) => {
225 if (!open && !sending) close();
226 }}
227 >
228 <Dialog size="lg" className="p-6 return-dialog collision-dialog">
229 <Dialog.Title>Mark this collision resolved?</Dialog.Title>
230 <Dialog.Description render={<div />}>
231 <CollisionSentence blocker={blocker} />
232 <div className="collision-evidence caption">
233 {blocker.paths.slice(0, 5).map((path) => (
234 <p key={path}>
235 <code>{path}</code>
236 </p>
237 ))}
238 {blocker.paths.length > 5 && (
239 <Tooltip content={blocker.paths.join("\n")} render={<span tabIndex={0} />}>
240 +{blocker.paths.length - 5} more
241 </Tooltip>
242 )}
243 </div>
244 <p>
245 Marking it resolved unblocks {blockedLabel(blocker)}: its reviews can be approved and
246 promoted again. Nothing in either fork changes.
247 </p>
248 </Dialog.Description>
249 <div role="group" aria-label="Quick reasons" className="collision-quick-reasons">
250 {quickReasons.map((value, index) => (
251 <Button
252 key={value}
253 ref={index === 0 ? firstReason : undefined}
254 autoFocus={index === 0}
255 variant="secondary"
256 size="sm"
257 disabled={sending || missing}
258 onClick={() => {
259 setReason(value);
260 textarea.current?.focus();
261 }}
262 >
263 {value}
264 </Button>
265 ))}
266 </div>
267 <label htmlFor={`${id}-reason`}>How was it resolved?</label>
268 <Textarea
269 ref={textarea}
270 id={`${id}-reason`}
271 required
272 minLength={1}
273 maxLength={200}
274 aria-describedby={`${id}-counter`}
275 value={reason}
276 disabled={sending}
277 readOnly={missing}
278 onChange={(e) => setReason(e.target.value)}
279 onKeyDown={(e) => {
280 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
281 e.preventDefault();
282 void submit();
283 }
284 }}
285 />
286 <p id={`${id}-counter`} className="caption muted" aria-live="polite">
287 {reason.length} / 200
288 </p>
289 {error && (
290 <p role="alert" className="receipt-error-line">
291 <Warning size={20} aria-hidden="true" />
292 <span>{error}</span>
293 </p>
294 )}
295 <div className="dialog-actions">
296 <Button ref={closeButton} variant="secondary" disabled={sending} onClick={() => close()}>
297 {missing ? "Close" : "Cancel"}
298 </Button>
299 {!missing && (
300 <WriteHint reason={disabledReason}><Button
301 variant="primary"
302 data-action="confirm-resolve"
303 disabled={disabled || sending || reason.trim().length < 1 || reason.length > 200}
304 onClick={() => void submit()}
305 >
306 Mark resolved
307 </Button></WriteHint>
308 )}
309 </div>
310 </Dialog>
311 </Dialog.Root>
312 );
313}
314