1import { useAtomValue } from "@effect/atom-react";
2import type { PullRequestAction } from "@t3tools/contracts";
3import { pullRequestEnvironment } from "~/state/pullRequests";
4import { useUiStateStore } from "~/uiStateStore";
5import { Button } from "../ui/button";
6import { Spinner } from "../ui/spinner";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic";
9import { PullRequestGlyph } from "./pullRequestIcons";
10import { pullRequestQuickActions, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
11import {
12 usePullRequestActionRunner,
13 usePullRequestDefaultMergeMethodResolver,
14} from "./usePullRequestActions";
15
16type PullRequestSpeedActionEntry = Pick<
17 EnvironmentPullRequestEntry,
18 "environmentId" | "projectId" | "host" | "repository" | "number" | "state" | "isDraft"
19> & { readonly stack?: object | undefined };
20
21export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntry> {
22 readonly entry: Entry;
23 readonly action: PullRequestAction;
24}
25
26/** No detail or stack reads until a merge is clicked, even on a long list. */
27export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({
28 entry,
29 hostActions,
30 visible,
31 onActed,
32 closing = false,
33 sweeping = false,
34 onCloseSweepStart,
35}: {
36 entry: Entry;
37 /** What the row's host can do; actions it cannot are not offered. */
38 hostActions: ReadonlySet<PullRequestAction>;
39 visible: boolean;
40 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;
41 closing?: boolean;
42 sweeping?: boolean;
43 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;
44}) {
45 const canWrite = useAtomValue(
46 pullRequestEnvironment.runAction.permissionAtom(entry.environmentId),
47 );
48 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
49 entry.environmentId,
50 entry.projectId,
51 );
52 const reference = {
53 projectId: entry.projectId,
54 host: entry.host,
55 repository: entry.repository,
56 number: entry.number,
57 };
58 const { actionPending, perform } = usePullRequestActionRunner({
59 environmentId: entry.environmentId,
60 reference,
61 onSuccess: (action) => onActed?.({ entry, action }),
62 resolveMergeMethod: (detail) => {
63 const allowed = detail.capabilities.mergeMethods.filter(
64 (method) => detail.mergeCapabilities[method],
65 );
66 if (allowed.length === 0)
67 throw new Error("No merge method is available for this repository.");
68 return resolvePullRequestMergeMethod(
69 allowed,
70 null,
71 resolveProjectDefault(),
72 useUiStateStore.getState().pullRequestMergeMethod,
73 );
74 },
75 });
76 const actions = pullRequestQuickActions(entry, hostActions);
77 const busy = actionPending || closing || sweeping;
78 if (actions.length === 0) return null;
79 return (
80 <div
81 className="shrink-0 items-center gap-1 pr-3"
82 style={{ display: visible || busy ? "flex" : "none" }}
83 role="group"
84 aria-label={`Quick actions for pull request #${entry.number}`}
85 data-pull-request-action-pending={actionPending || closing}
86 >
87 {actions.map((action) => {
88 const label = ACTIONS[action].label;
89 const Icon = ACTIONS[action].Icon;
90 return (
91 <Tooltip key={action}>
92 <TooltipTrigger
93 render={
94 <Button
95 variant={action === "close" ? "destructive-outline" : "outline"}
96 size="xs"
97 disabled={!canWrite || busy || (action === "merge" && entry.stack !== undefined)}
98 aria-label={`${label} #${entry.number}`}
99 onClick={() => void perform(action)}
100 onPointerDown={(event) => {
101 if (action !== "close" || !event.isPrimary || event.button !== 0) return;
102 event.stopPropagation();
103 onCloseSweepStart?.(entry, event.nativeEvent);
104 }}
105 />
106 }
107 >
108 {busy ? <Spinner size="xs" /> : <Icon aria-hidden className="size-3" />}
109 {label}
110 </TooltipTrigger>
111 <TooltipPopup>
112 {action === "merge" && entry.stack
113 ? "Open this pull request to merge its stack"
114 : action === "close" && onCloseSweepStart
115 ? "Close immediately, or drag across rows to close several"
116 : `${label} immediately`}
117 </TooltipPopup>
118 </Tooltip>
119 );
120 })}
121 </div>
122 );
123}
124
125const ACTIONS = {
126 close: { label: "Close", Icon: PullRequestGlyph.closed },
127 merge: { label: "Merge", Icon: PullRequestGlyph.merged },
128 ready: { label: "Ready for review", Icon: PullRequestGlyph.pullRequest },
129 reopen: { label: "Reopen", Icon: PullRequestGlyph.reopen },
130} as const;