Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/collisions.tsx

Snapshot at 34e3175

Containing directory

10,107 bytes · textView raw
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