authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 07:58:30-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 07:58:30-03:00
log33b7d7b3ae2a7b1c3d1bd58c04cb9bf581ec2fe7
tree64a76552a3ffb54f4b43f9887bc81db9333cc444
parenteac52f0087d9ba5dee5542f24788d1482affae43
signature Signed by PGP key B5690EEEBB952194

feat(web): add shift-held pull request quick actions (#15549)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

8 files changed, 392 insertions(+), 199 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+1
...@@ -32,6 +32,7 @@ vi.mock("~/state/environments", () => ({...@@ -32,6 +32,7 @@ vi.mock("~/state/environments", () => ({
32 usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"),32 usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"),
33}));33}));
34vi.mock("~/hooks/useSettings", () => ({34vi.mock("~/hooks/useSettings", () => ({
35 useEnvironmentSettings: () => undefined,
35 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>36 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
36 select(DEFAULT_CLIENT_SETTINGS),37 select(DEFAULT_CLIENT_SETTINGS),
37}));38}));
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+10-50
...@@ -6,7 +6,6 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro...@@ -6,7 +6,6 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro
6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import {7import {
8 type EnvironmentId,8 type EnvironmentId,
9 DEFAULT_SERVER_SETTINGS,
10 type PullRequestAction,9 type PullRequestAction,
11 type PullRequestMergeMethod,10 type PullRequestMergeMethod,
12 type PullRequestListEntry,11 type PullRequestListEntry,
...@@ -15,7 +14,6 @@ import {...@@ -15,7 +14,6 @@ import {
15 resolveEnvironmentMachineKind,14 resolveEnvironmentMachineKind,
16 type ScopedThreadRef,15 type ScopedThreadRef,
17} from "@t3tools/contracts";16} from "@t3tools/contracts";
18import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
19import {17import {
20 ArrowDownUpIcon,18 ArrowDownUpIcon,
21 ArrowLeftIcon,19 ArrowLeftIcon,
...@@ -63,20 +61,14 @@ import {...@@ -63,20 +61,14 @@ import {
63 type ShortcutMatchContext,61 type ShortcutMatchContext,
64} from "~/keybindings";62} from "~/keybindings";
65import { primaryServerKeybindingsAtom } from "~/state/server";63import { primaryServerKeybindingsAtom } from "~/state/server";
66import { useClientSettings } from "~/hooks/useSettings";64import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions";
67import {
68 deriveLogicalProjectKeyFromSettings,
69 derivePhysicalProjectKey,
70 selectProjectGroupingSettings,
71} from "~/logicalProject";
72import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";65import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
73import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";66import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
74import { cn } from "~/lib/utils";67import { cn } from "~/lib/utils";
75import { readLocalApi } from "~/localApi";68import { readLocalApi } from "~/localApi";
76import type { ReviewCommentContext } from "~/reviewCommentContext";69import type { ReviewCommentContext } from "~/reviewCommentContext";
77import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
78import { useProjects, useServerConfigs } from "~/state/entities";70import { useProjects, useServerConfigs } from "~/state/entities";
79import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";71import { useEnvironments } from "~/state/environments";
80import { useEnvironmentQuery } from "~/state/query";72import { useEnvironmentQuery } from "~/state/query";
81import { useLiveRefresh } from "~/hooks/useLiveRefresh";73import { useLiveRefresh } from "~/hooks/useLiveRefresh";
82import {74import {
...@@ -563,19 +555,14 @@ export function PullRequestDetailPanel({...@@ -563,19 +555,14 @@ export function PullRequestDetailPanel({
563 }, [condensed]);555 }, [condensed]);
564 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);556 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
565 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);557 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
566 // Server-side and per project, like every other project setting. The558 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
567 // client-local per-project map from before still answers when the server559 environmentId,
568 // has no value, so a choice made on an older release keeps applying until560 reference.projectId,
569 // it is set (or reset) in Settings.561 );
570 const legacyMergeMethodOverrides = useClientSettings(562 const projectDefaultMergeMethod = useMemo(
571 (settings) => settings.pullRequestMergeMethodOverrides,563 () => resolveProjectDefaultMergeMethod(),
564 [resolveProjectDefaultMergeMethod],
572 );565 );
573 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
574 const projectDefaultMergeMethod =
575 resolveProjectSettings(
576 environmentConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS,
577 reference.projectId,
578 ).settings.pullRequestMergeMethod ?? undefined;
579 const [mergeMethodSelection, setMergeMethodSelection] = useState<{566 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
580 readonly pullRequestKey: string;567 readonly pullRequestKey: string;
581 readonly method: PullRequestMergeMethod;568 readonly method: PullRequestMergeMethod;
...@@ -887,39 +874,12 @@ export function PullRequestDetailPanel({...@@ -887,39 +874,12 @@ export function PullRequestDetailPanel({
887 const [titleSaving, setTitleSaving] = useState(false);874 const [titleSaving, setTitleSaving] = useState(false);
888 const newThread = useNewThreadHandler();875 const newThread = useNewThreadHandler();
889 const { environments } = useEnvironments();876 const { environments } = useEnvironments();
890 const primaryEnvironmentId = usePrimaryEnvironmentId();
891 const unavailableGitHubUrl = useMemo(() => {877 const unavailableGitHubUrl = useMemo(() => {
892 const identity = projects.find(878 const identity = projects.find(
893 (project) => project.id === reference.projectId && project.environmentId === environmentId,879 (project) => project.id === reference.projectId && project.environmentId === environmentId,
894 )?.repositoryIdentity;880 )?.repositoryIdentity;
895 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);881 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
896 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);882 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
897 // Project settings stored the override under the sidebar group's key, which a duplicate row
898 // borrows from its siblings, so the project alone does not always name the same key.
899 const legacyProjectDefaultMergeMethod = useMemo(() => {
900 if (projectDefaultMergeMethod !== undefined) return undefined;
901 const project = projects.find(
902 (candidate) =>
903 candidate.environmentId === environmentId && candidate.id === reference.projectId,
904 );
905 if (!project) return undefined;
906 const projectKey =
907 buildPhysicalToLogicalProjectKeyMap({
908 projects,
909 settings: projectGroupingSettings,
910 primaryEnvironmentId,
911 }).get(derivePhysicalProjectKey(project)) ??
912 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
913 return legacyMergeMethodOverrides[projectKey];
914 }, [
915 environmentId,
916 legacyMergeMethodOverrides,
917 primaryEnvironmentId,
918 projectDefaultMergeMethod,
919 projectGroupingSettings,
920 projects,
921 reference.projectId,
922 ]);
923 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and883 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
924 // the thread is already on one server's copy of the branch.884 // the thread is already on one server's copy of the branch.
925 const pickableEnvironments = useMemo(885 const pickableEnvironments = useMemo(
...@@ -1421,7 +1381,7 @@ export function PullRequestDetailPanel({...@@ -1421,7 +1381,7 @@ export function PullRequestDetailPanel({
1421 const selectedMergeMethod = resolvePullRequestMergeMethod(1381 const selectedMergeMethod = resolvePullRequestMergeMethod(
1422 allowedMergeMethods,1382 allowedMergeMethods,
1423 currentMergeMethod,1383 currentMergeMethod,
1424 projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod,1384 projectDefaultMergeMethod,
1425 lastSelectedMergeMethod,1385 lastSelectedMergeMethod,
1426 );1386 );
1427 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];1387 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
apps/web/src/components/pullRequest/PullRequestRow.tsx+140-127
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { SearchIcon } from "lucide-react";1import { SearchIcon } from "lucide-react";
2import { PullRequestStackPopover } from "./PullRequestStackPopover";2import { PullRequestStackPopover } from "./PullRequestStackPopover";
3import {
4 PullRequestSpeedActions,
5 type PullRequestSpeedActionResult,
6} from "./PullRequestSpeedActions";
3import { memo, type RefCallback } from "react";7import { memo, type RefCallback } from "react";
48
5import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
...@@ -66,10 +70,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[...@@ -66,10 +70,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[
6670
67/**71/**
68 * The page row keeps a little more room around the shared lines than the panel, which sits in72 * The page row keeps a little more room around the shared lines than the panel, which sits in
69 * a narrow column. The intrinsic size is the content box a skipped row reserves, which is the73 * a narrow column. Its outer wrapper reserves the full row height when offscreen.
70 * two lines without the padding: a 56px row less 20px of `py-2.5`.
71 */74 */
72const PAGE_ROW_CLASS = "px-3 py-2.5 [contain-intrinsic-block-size:36.5px]";75const PAGE_ROW_CLASS = "px-3 py-2.5";
7376
74export type PullRequestRowTarget = Pick<77export type PullRequestRowTarget = Pick<
75 EnvironmentPullRequestEntry,78 EnvironmentPullRequestEntry,
...@@ -86,6 +89,8 @@ function PullRequestRowImpl({...@@ -86,6 +89,8 @@ function PullRequestRowImpl({
86 statsKey,89 statsKey,
87 statsRef,90 statsRef,
88 onSelect,91 onSelect,
92 speedMode,
93 onActed,
89}: {94}: {
90 entry: EnvironmentPullRequestEntry;95 entry: EnvironmentPullRequestEntry;
91 selected: boolean;96 selected: boolean;
...@@ -101,144 +106,152 @@ function PullRequestRowImpl({...@@ -101,144 +106,152 @@ function PullRequestRowImpl({
101 matchedElsewhere?: boolean;106 matchedElsewhere?: boolean;
102 /** Used by the list's shared visibility observer to defer optional line-count reads. */107 /** Used by the list's shared visibility observer to defer optional line-count reads. */
103 statsKey?: string;108 statsKey?: string;
104 statsRef?: RefCallback<HTMLButtonElement>;109 statsRef?: RefCallback<HTMLDivElement>;
105 onSelect: (entry: PullRequestRowTarget) => void;110 onSelect: (entry: PullRequestRowTarget) => void;
111 speedMode: boolean;
112 onActed: (result: PullRequestSpeedActionResult) => void;
106}) {113}) {
107 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);114 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
108 return (115 return (
109 <button116 <div
110 ref={statsRef}117 ref={statsRef}
111 data-pull-request-stats-key={statsKey}118 data-pull-request-stats-key={statsKey}
112 type="button"
113 aria-current={selected ? "true" : undefined}
114 onClick={() => onSelect(entry)}
115 className={cn(119 className={cn(
116 PULL_REQUEST_ROW_CLASS,120 // Keep the action buttons inside the same skipped row, but outside its selection button.
117 PAGE_ROW_CLASS,121 "flex items-center rounded-md [content-visibility:auto] [contain-intrinsic-block-size:56.5px]",
118 "cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
119 // Offscreen rows are skipped for style, layout and paint: a long list costs what the
120 // viewport shows, not what the pages have loaded. The intrinsic size keeps the
121 // scrollbar honest while a row is skipped.
122 "[content-visibility:auto]",
123 selected ? "bg-accent" : "hover:bg-accent/60",122 selected ? "bg-accent" : "hover:bg-accent/60",
124 )}123 )}
125 >124 >
126 <PullRequestRowGlyph125 <button
127 state={entry.state}126 type="button"
128 isDraft={entry.isDraft}127 aria-current={selected ? "true" : undefined}
129 mergeability={entry.mergeability}128 onClick={() => onSelect(entry)}
130 baseBranch={entry.baseBranch}129 className={cn(
131 // On the title line rather than between the lines, as main aligns it.130 PULL_REQUEST_ROW_CLASS,
132 className="mt-0.75 self-start"131 PAGE_ROW_CLASS,
133 />132 "min-w-0 flex-1 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
134 <PullRequestRowLines133 )}
135 number={134 >
136 // The number carries the link, here as much as on the detail: a right-click on it135 <PullRequestRowGlyph
137 // copies the pull request's own address rather than opening the editing menu.136 state={entry.state}
138 <span137 isDraft={entry.isDraft}
139 className={PULL_REQUEST_ROW_NUMBER_CLASS}138 mergeability={entry.mergeability}
140 onContextMenu={(event) => {139 baseBranch={entry.baseBranch}
141 event.preventDefault();140 // On the title line rather than between the lines, as main aligns it.
142 event.stopPropagation();141 className="mt-0.75 self-start"
143 void showPullRequestLinkContextMenu({142 />
144 url: entry.url,143 <PullRequestRowLines
145 openLabel: openOnHostLabel(entry.provider),144 number={
146 position: { x: event.clientX, y: event.clientY },145 // The number carries the link, here as much as on the detail: a right-click on it
147 });146 // copies the pull request's own address rather than opening the editing menu.
148 }}147 <span
149 >148 className={PULL_REQUEST_ROW_NUMBER_CLASS}
150 #{entry.number}149 onContextMenu={(event) => {
151 </span>150 event.preventDefault();
152 }151 event.stopPropagation();
153 title={entry.title}152 void showPullRequestLinkContextMenu({
154 signals={153 url: entry.url,
155 <>154 openLabel: openOnHostLabel(entry.provider),
156 {entry.checksState === undefined ? null : (155 position: { x: event.clientX, y: event.clientY },
157 <PullRequestChecksPopover156 });
158 checksState={entry.checksState}157 }}
159 environmentId={entry.environmentId}158 >
160 reference={{159 #{entry.number}
161 projectId: entry.projectId,160 </span>
162 repository: entry.repository,161 }
163 number: entry.number,162 title={entry.title}
164 }}163 signals={
165 />164 <>
166 )}165 {entry.checksState === undefined ? null : (
167 {/* Only a verdict the host actually reports: an approval, a request for changes,166 <PullRequestChecksPopover
167 checksState={entry.checksState}
168 environmentId={entry.environmentId}
169 reference={{
170 projectId: entry.projectId,
171 repository: entry.repository,
172 number: entry.number,
173 }}
174 />
175 )}
176 {/* Only a verdict the host actually reports: an approval, a request for changes,
168 or a review the branch rules still require. No glyph on the common case of a177 or a review the branch rules still require. No glyph on the common case of a
169 pull request nobody has reviewed, so a row only wears a person when the person178 pull request nobody has reviewed, so a row only wears a person when the person
170 has said something. */}179 has said something. */}
171 {entry.reviewDecision === undefined ? null : (180 {entry.reviewDecision === undefined ? null : (
172 <PullRequestReviewDecisionGlyph decision={entry.reviewDecision} />181 <PullRequestReviewDecisionGlyph decision={entry.reviewDecision} />
173 )}182 )}
174 </>183 </>
175 }184 }
176 status={185 status={
177 <>186 <>
178 {entry.stack ? (187 {entry.stack ? (
179 <PullRequestStackPopover188 <PullRequestStackPopover
180 environmentId={entry.environmentId}189 environmentId={entry.environmentId}
181 reference={{190 reference={{
182 projectId: entry.projectId,191 projectId: entry.projectId,
183 host: entry.host,192 host: entry.host,
184 repository: entry.repository,193 repository: entry.repository,
185 number: entry.number,194 number: entry.number,
186 }}195 }}
187 membership={entry.stack}196 membership={entry.stack}
188 onSelect={(target) =>197 onSelect={(target) =>
189 onSelect({ ...target, host: entry.host, environmentId: entry.environmentId })198 onSelect({ ...target, host: entry.host, environmentId: entry.environmentId })
190 }
191 />
192 ) : null}
193 <PullRequestDiffStat
194 additions={entry.additions}
195 deletions={entry.deletions}
196 className="font-mono"
197 />
198 </>
199 }
200 metaClassName="@container/pr-row-meta"
201 meta={
202 <>
203 {matchedElsewhere ? (
204 <Tooltip>
205 <TooltipTrigger
206 render={
207 <span className="flex min-w-6 items-center gap-1 overflow-hidden rounded-full border border-border/60 px-1 text-3xs" />
208 }199 }
209 >200 />
210 <span className="sr-only">matched in the description</span>201 ) : null}
211 <SearchIcon aria-hidden className="size-3 shrink-0" />202 <PullRequestDiffStat
212 <span aria-hidden className="hidden truncate @xs/pr-row-meta:block">203 additions={entry.additions}
213 matched in the description204 deletions={entry.deletions}
214 </span>205 className="font-mono"
215 </TooltipTrigger>206 />
216 <TooltipPopup side="top">Matched in the description</TooltipPopup>207 </>
217 </Tooltip>208 }
218 ) : null}209 metaClassName="@container/pr-row-meta"
219 {showProvider ? (210 meta={
220 <Tooltip>211 <>
221 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>212 {matchedElsewhere ? (
222 <Icon aria-label={providerName} className="size-3" />213 <Tooltip>
223 </TooltipTrigger>214 <TooltipTrigger
224 <TooltipPopup>{providerName}</TooltipPopup>215 render={
225 </Tooltip>216 <span className="flex min-w-6 items-center gap-1 overflow-hidden rounded-full border border-border/60 px-1 text-3xs" />
226 ) : null}217 }
227 <PullRequestRowAuthor218 >
228 actor={entry.author}219 <span className="sr-only">matched in the description</span>
229 className="min-w-3.5 max-w-40"220 <SearchIcon aria-hidden className="size-3 shrink-0" />
230 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"221 <span aria-hidden className="hidden truncate @xs/pr-row-meta:block">
231 />222 matched in the description
232 {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null}223 </span>
233 {environmentLabel ? (224 </TooltipTrigger>
234 <span className="min-w-0 max-w-32 truncate">{environmentLabel}</span>225 <TooltipPopup side="top">Matched in the description</TooltipPopup>
235 ) : null}226 </Tooltip>
236 {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null}227 ) : null}
237 </>228 {showProvider ? (
238 }229 <Tooltip>
239 updatedAt={entry.updatedAt}230 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
240 />231 <Icon aria-label={providerName} className="size-3" />
241 </button>232 </TooltipTrigger>
233 <TooltipPopup>{providerName}</TooltipPopup>
234 </Tooltip>
235 ) : null}
236 <PullRequestRowAuthor
237 actor={entry.author}
238 className="min-w-3.5 max-w-40"
239 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"
240 />
241 {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null}
242 {environmentLabel ? (
243 <span className="min-w-0 max-w-32 truncate">{environmentLabel}</span>
244 ) : null}
245 {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null}
246 </>
247 }
248 updatedAt={entry.updatedAt}
249 />
250 </button>
251 {entry.state !== "merged" && entry.provider === "github" ? (
252 <PullRequestSpeedActions entry={entry} visible={speedMode} onActed={onActed} />
253 ) : null}
254 </div>
242 );255 );
243}256}
244257
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx created+135
...@@ -0,0 +1,135 @@
1import type { PullRequestAction } from "@t3tools/contracts";
2import { Effect } from "effect";
3import { AtomRegistry } from "effect/unstable/reactivity";
4import { appAtomRegistry } from "~/rpc/atomRegistry";
5import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
6import { useUiStateStore } from "~/uiStateStore";
7import { Button } from "../ui/button";
8import { Spinner } from "../ui/spinner";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
10import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic";
11import { PullRequestGlyph } from "./pullRequestIcons";
12import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
13import {
14 usePullRequestActionRunner,
15 usePullRequestDefaultMergeMethodResolver,
16} from "./usePullRequestActions";
17
18export interface PullRequestSpeedActionResult {
19 readonly entry: EnvironmentPullRequestEntry;
20 readonly action: PullRequestAction;
21}
22
23/** No detail or stack reads until a merge is clicked, even on a long list. */
24export function PullRequestSpeedActions({
25 entry,
26 visible,
27 onActed,
28}: {
29 entry: EnvironmentPullRequestEntry;
30 visible: boolean;
31 onActed: (result: PullRequestSpeedActionResult) => void;
32}) {
33 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
34 entry.environmentId,
35 entry.projectId,
36 );
37 const reference = {
38 projectId: entry.projectId,
39 host: entry.host,
40 repository: entry.repository,
41 number: entry.number,
42 };
43 const { actionPending, perform } = usePullRequestActionRunner({
44 environmentId: entry.environmentId,
45 reference,
46 onSuccess: (action) => onActed({ entry, action }),
47 resolveMergeMethod: async () => {
48 const target = { environmentId: entry.environmentId, input: reference };
49 const detailAtom = pullRequestEnvironment.detail({
50 ...target,
51 input: { ...reference, allowStale: false },
52 });
53 appAtomRegistry.refresh(detailAtom);
54 const detail = await Effect.runPromise(
55 AtomRegistry.getResult(appAtomRegistry, detailAtom, { suspendOnWaiting: true }),
56 );
57 if (
58 detail.state !== "open" ||
59 detail.isDraft ||
60 !detail.capabilities.actions.includes("merge") ||
61 !detail.viewerPermissions.actions.includes("merge")
62 ) {
63 throw new Error("This pull request cannot be merged.");
64 }
65 if (detail.capabilities.stackActions) {
66 const stackAtom = pullRequestStackAtom(target);
67 appAtomRegistry.refresh(stackAtom);
68 const stack = await Effect.runPromise(
69 AtomRegistry.getResult(appAtomRegistry, stackAtom, { suspendOnWaiting: true }),
70 );
71 if (stack !== null) throw new Error("Open this pull request to merge its stack.");
72 }
73 const allowed = detail.capabilities.mergeMethods.filter(
74 (method) => detail.mergeCapabilities[method],
75 );
76 if (allowed.length === 0)
77 throw new Error("No merge method is available for this repository.");
78 return resolvePullRequestMergeMethod(
79 allowed,
80 null,
81 resolveProjectDefault(),
82 useUiStateStore.getState().pullRequestMergeMethod,
83 );
84 },
85 });
86 const actions =
87 entry.state === "closed"
88 ? (["reopen"] as const)
89 : entry.isDraft
90 ? (["close", "ready"] as const)
91 : (["close", "merge"] as const);
92 return (
93 <div
94 className="shrink-0 items-center gap-1 pr-3"
95 style={{ display: visible || actionPending ? "flex" : "none" }}
96 role="group"
97 aria-label={`Quick actions for pull request #${entry.number}`}
98 >
99 {actions.map((action) => {
100 const label = ACTIONS[action].label;
101 const Icon = ACTIONS[action].Icon;
102 return (
103 <Tooltip key={action}>
104 <TooltipTrigger
105 render={
106 <Button
107 variant={action === "close" ? "destructive-outline" : "outline"}
108 size="xs"
109 disabled={actionPending || (action === "merge" && entry.stack !== undefined)}
110 aria-label={`${label} #${entry.number}`}
111 onClick={() => void perform(action)}
112 />
113 }
114 >
115 {actionPending ? <Spinner size="xs" /> : <Icon aria-hidden className="size-3" />}
116 {label}
117 </TooltipTrigger>
118 <TooltipPopup>
119 {action === "merge" && entry.stack
120 ? "Open this pull request to merge its stack"
121 : `${label} immediately`}
122 </TooltipPopup>
123 </Tooltip>
124 );
125 })}
126 </div>
127 );
128}
129
130const ACTIONS = {
131 close: { label: "Close", Icon: PullRequestGlyph.closed },
132 merge: { label: "Merge", Icon: PullRequestGlyph.merged },
133 ready: { label: "Ready for review", Icon: PullRequestGlyph.pullRequest },
134 reopen: { label: "Reopen", Icon: PullRequestGlyph.reopen },
135} as const;
apps/web/src/components/pullRequest/pullRequestChecks.test.tsx+2
...@@ -102,6 +102,8 @@ function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {...@@ -102,6 +102,8 @@ function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {
102 showProjectTitle: false,102 showProjectTitle: false,
103 showProvider: false,103 showProvider: false,
104 onSelect: () => {},104 onSelect: () => {},
105 speedMode: false,
106 onActed: () => {},
105 });107 });
106}108}
107109
apps/web/src/components/pullRequest/usePullRequestActions.ts+72-17
...@@ -8,12 +8,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment";...@@ -8,12 +8,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment";
8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import type {9import type {
10 EnvironmentId,10 EnvironmentId,
11 ProjectId,
11 PullRequestAction,12 PullRequestAction,
12 PullRequestDetail,13 PullRequestDetail,
13 PullRequestMergeMethod,14 PullRequestMergeMethod,
14 PullRequestRef,15 PullRequestRef,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { useState } from "react";17import { useCallback, useRef, useState } from "react";
18import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
19import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings";
20import {
21 deriveLogicalProjectKeyFromSettings,
22 derivePhysicalProjectKey,
23 selectProjectGroupingSettings,
24} from "~/logicalProject";
25import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
26import { useProjects } from "~/state/entities";
27import { usePrimaryEnvironmentId } from "~/state/environments";
1728
18import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";29import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
19import { useNewThreadHandler } from "~/hooks/useHandleNewThread";30import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
...@@ -25,8 +36,48 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -25,8 +36,48 @@ import { useAtomCommand } from "~/state/use-atom-command";
25import { toastManager } from "../ui/toast";36import { toastManager } from "../ui/toast";
26import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";37import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";
2738
39/** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */
40export function usePullRequestDefaultMergeMethodResolver(
41 environmentId: EnvironmentId,
42 projectId: ProjectId,
43) {
44 const projectDefault = useEnvironmentSettings(
45 environmentId,
46 (settings) => resolveProjectSettings(settings, projectId).settings.pullRequestMergeMethod,
47 );
48 const legacyOverrides = useClientSettings((settings) => settings.pullRequestMergeMethodOverrides);
49 const grouping = useClientSettings(selectProjectGroupingSettings);
50 const projects = useProjects();
51 const primaryEnvironmentId = usePrimaryEnvironmentId();
52 return useCallback(() => {
53 if (projectDefault != null) return projectDefault;
54 if (Object.keys(legacyOverrides).length === 0) return undefined;
55 const project = projects.find(
56 (candidate) => candidate.environmentId === environmentId && candidate.id === projectId,
57 );
58 if (!project) return undefined;
59 // Duplicate sidebar rows borrow their logical group key from their siblings.
60 const key =
61 buildPhysicalToLogicalProjectKeyMap({
62 projects,
63 settings: grouping,
64 primaryEnvironmentId,
65 }).get(derivePhysicalProjectKey(project)) ??
66 deriveLogicalProjectKeyFromSettings(project, grouping);
67 return legacyOverrides[key];
68 }, [
69 projectDefault,
70 projects,
71 environmentId,
72 projectId,
73 grouping,
74 primaryEnvironmentId,
75 legacyOverrides,
76 ]);
77}
78
28const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {79const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
29 merge: "Pull request merged",80 merge: "Merge requested",
30 ready: "Marked ready for review",81 ready: "Marked ready for review",
31 draft: "Converted to draft",82 draft: "Converted to draft",
32 close: "Pull request closed",83 close: "Pull request closed",
...@@ -80,37 +131,41 @@ export function usePullRequestActionRunner({...@@ -80,37 +131,41 @@ export function usePullRequestActionRunner({
80 environmentId,131 environmentId,
81 reference,132 reference,
82 onSuccess,133 onSuccess,
134 resolveMergeMethod,
83}: {135}: {
84 environmentId: EnvironmentId;136 environmentId: EnvironmentId;
85 reference: PullRequestRef | null;137 reference: PullRequestRef | null;
86 onSuccess?: (action: PullRequestAction) => void;138 onSuccess?: (action: PullRequestAction) => void;
139 /** Small surfaces resolve repository settings on the click, not for every visible row. */
140 resolveMergeMethod?: () => Promise<PullRequestMergeMethod>;
87}) {141}) {
88 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });142 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
89 const [actionPending, setActionPending] = useState(false);143 const [actionPending, setActionPending] = useState(false);
144 const pendingRef = useRef(false);
90145
91 const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {146 const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {
92 if (actionPending || reference === null) return;147 if (pendingRef.current || reference === null) return;
148 pendingRef.current = true;
93 setActionPending(true);149 setActionPending(true);
94 const result = await runAction({150 try {
95 environmentId,151 const mergeMethod = method ?? (action === "merge" ? await resolveMergeMethod?.() : undefined);
96 input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) },152 const result = await runAction({
97 });153 environmentId,
98 setActionPending(false);154 input: { ...reference, action, ...(mergeMethod ? { mergeMethod } : {}) },
99 if (result._tag === "Failure") {155 });
100 // The host's own sentence, because it is the only thing that says why. A merge strategy a156 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
101 // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps157 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
102 // publishes no per-strategy availability to hide the control with — so "action failed"158 onSuccess?.(action);
103 // would leave the reader pressing the same button again.159 } catch (failure) {
104 const failure = squashAtomCommandFailure(result);
105 toastManager.add({160 toastManager.add({
106 type: "error",161 type: "error",
107 title: ACTION_FAILURE_LABELS[action],162 title: ACTION_FAILURE_LABELS[action],
108 description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),163 description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),
109 });164 });
110 return;165 } finally {
166 pendingRef.current = false;
167 setActionPending(false);
111 }168 }
112 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
113 onSuccess?.(action);
114 };169 };
115170
116 return { actionPending, perform };171 return { actionPending, perform };
apps/web/src/routes/_chat.pull-requests.tsx+19-2
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { Spinner } from "~/components/ui/spinner";2import { Spinner } from "~/components/ui/spinner";
3import { useShortcutModifierState } from "~/shortcutModifierState";
4import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions";
3import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";5import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
4import type {6import type {
5 EnvironmentId,7 EnvironmentId,
...@@ -342,6 +344,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -342,6 +344,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({
342344
343function PullRequestsRouteView() {345function PullRequestsRouteView() {
344 useEscapeToGoBack();346 useEscapeToGoBack();
347 const modifiers = useShortcutModifierState(true);
348 const speedMode =
349 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
345 const search = Route.useSearch();350 const search = Route.useSearch();
346 const sort = search.sort ?? "ready";351 const sort = search.sort ?? "ready";
347 const statsPolicy: PullRequestStatsPolicy =352 const statsPolicy: PullRequestStatsPolicy =
...@@ -952,6 +957,16 @@ function PullRequestsRouteView() {...@@ -952,6 +957,16 @@ function PullRequestsRouteView() {
952 };957 };
953 /** The detail panel's own writes, by row, so its failure takes back its own note. */958 /** The detail panel's own writes, by row, so its failure takes back its own note. */
954 const detailOverrideTokens = useRef(new Map<string, number | null>());959 const detailOverrideTokens = useRef(new Map<string, number | null>());
960 const speedActionRef = useRef<(result: PullRequestSpeedActionResult) => void>(() => {});
961 speedActionRef.current = ({ entry, action }) => {
962 // Some hosts accept a merge before it completes. Let the next host read declare it merged.
963 if (action !== "merge") overrideEntry(entry, action);
964 setDetailRefreshToken((token) => token + 1);
965 refreshListAndStats(undefined, entry.environmentId);
966 };
967 const onSpeedAction = useCallback((result: PullRequestSpeedActionResult) => {
968 speedActionRef.current(result);
969 }, []);
955 // A reload recreates the registry the queries live in, so with nothing held the page would970 // A reload recreates the registry the queries live in, so with nothing held the page would
956 // cold-start into skeletons even though almost every row is unchanged. The last answer for971 // cold-start into skeletons even though almost every row is unchanged. The last answer for
957 // this set of environments is kept across reloads and hydrated here as the carried rows: they972 // this set of environments is kept across reloads and hydrated here as the carried rows: they
...@@ -1395,11 +1410,11 @@ function PullRequestsRouteView() {...@@ -1395,11 +1410,11 @@ function PullRequestsRouteView() {
1395 [statsBatches],1410 [statsBatches],
1396 );1411 );
1397 const statsObserver = useRef<IntersectionObserver | null>(null);1412 const statsObserver = useRef<IntersectionObserver | null>(null);
1398 const statsRows = useRef(new Set<HTMLButtonElement>());1413 const statsRows = useRef(new Set<HTMLDivElement>());
1399 const statsPending = useRef(true);1414 const statsPending = useRef(true);
1400 const statsPolicyRef = useRef(statsPolicy);1415 const statsPolicyRef = useRef(statsPolicy);
1401 statsPolicyRef.current = statsPolicy;1416 statsPolicyRef.current = statsPolicy;
1402 const registerStatsRow = useCallback((node: HTMLButtonElement | null) => {1417 const registerStatsRow = useCallback((node: HTMLDivElement | null) => {
1403 if (node === null || typeof IntersectionObserver === "undefined") return;1418 if (node === null || typeof IntersectionObserver === "undefined") return;
1404 statsRows.current.add(node);1419 statsRows.current.add(node);
1405 statsObserver.current?.observe(node);1420 statsObserver.current?.observe(node);
...@@ -1817,6 +1832,8 @@ function PullRequestsRouteView() {...@@ -1817,6 +1832,8 @@ function PullRequestsRouteView() {
1817 selected.number === entry.number1832 selected.number === entry.number
1818 }1833 }
1819 onSelect={selectEntry}1834 onSelect={selectEntry}
1835 speedMode={speedMode}
1836 onActed={onSpeedAction}
1820 />1837 />
1821 );1838 );
1822 })}1839 })}
apps/web/src/shortcutModifierState.ts+13-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useEffect, useRef, useState } from "react";1import { useEffect, useRef, useState } from "react";
2import { isEditableFocused } from "./lib/editableFocus";
23
3export interface ShortcutModifierState {4export interface ShortcutModifierState {
4 metaKey: boolean;5 metaKey: boolean;
...@@ -26,7 +27,7 @@ export function areShortcutModifierStatesEqual(...@@ -26,7 +27,7 @@ export function areShortcutModifierStatesEqual(
26 );27 );
27}28}
2829
29export function useShortcutModifierState(): ShortcutModifierState {30export function useShortcutModifierState(ignoreEditable = false): ShortcutModifierState {
30 const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE);31 const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE);
31 const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE);32 const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE);
3233
...@@ -39,7 +40,11 @@ export function useShortcutModifierState(): ShortcutModifierState {...@@ -39,7 +40,11 @@ export function useShortcutModifierState(): ShortcutModifierState {
39 setState(next);40 setState(next);
40 };41 };
41 const onKeyboardEvent = (event: KeyboardEvent) => {42 const onKeyboardEvent = (event: KeyboardEvent) => {
42 updateState(shortcutModifierStateAfterKeyboardEvent(stateRef.current, event));43 updateState(
44 ignoreEditable && isEditableFocused(event.target)
45 ? EMPTY_SHORTCUT_MODIFIER_STATE
46 : shortcutModifierStateAfterKeyboardEvent(stateRef.current, event),
47 );
43 };48 };
44 // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup49 // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup
45 // never reaches the page, so the tracked state stays "⌘ held" forever and50 // never reaches the page, so the tracked state stays "⌘ held" forever and
...@@ -50,17 +55,22 @@ export function useShortcutModifierState(): ShortcutModifierState {...@@ -50,17 +55,22 @@ export function useShortcutModifierState(): ShortcutModifierState {
50 updateState(EMPTY_SHORTCUT_MODIFIER_STATE);55 updateState(EMPTY_SHORTCUT_MODIFIER_STATE);
51 };56 };
5257
58 const onFocus = (event: FocusEvent) => {
59 if (ignoreEditable && isEditableFocused(event.target)) onResetEvent();
60 };
61 window.addEventListener("focusin", onFocus);
53 window.addEventListener("keydown", onKeyboardEvent, true);62 window.addEventListener("keydown", onKeyboardEvent, true);
54 window.addEventListener("keyup", onKeyboardEvent, true);63 window.addEventListener("keyup", onKeyboardEvent, true);
55 window.addEventListener("paste", onResetEvent, true);64 window.addEventListener("paste", onResetEvent, true);
56 window.addEventListener("blur", onResetEvent);65 window.addEventListener("blur", onResetEvent);
57 return () => {66 return () => {
67 window.removeEventListener("focusin", onFocus);
58 window.removeEventListener("keydown", onKeyboardEvent, true);68 window.removeEventListener("keydown", onKeyboardEvent, true);
59 window.removeEventListener("keyup", onKeyboardEvent, true);69 window.removeEventListener("keyup", onKeyboardEvent, true);
60 window.removeEventListener("paste", onResetEvent, true);70 window.removeEventListener("paste", onResetEvent, true);
61 window.removeEventListener("blur", onResetEvent);71 window.removeEventListener("blur", onResetEvent);
62 };72 };
63 }, []);73 }, [ignoreEditable]);
6474
65 return state;75 return state;
66}76}