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", () => ({
3232 usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"),
3333}));
3434vi.mock("~/hooks/useSettings", () => ({
35 useEnvironmentSettings: () => undefined,
3536 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
3637 select(DEFAULT_CLIENT_SETTINGS),
3738}));
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+10-50
......@@ -6,7 +6,6 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro
66import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
77import {
88 type EnvironmentId,
9 DEFAULT_SERVER_SETTINGS,
109 type PullRequestAction,
1110 type PullRequestMergeMethod,
1211 type PullRequestListEntry,
......@@ -15,7 +14,6 @@ import {
1514 resolveEnvironmentMachineKind,
1615 type ScopedThreadRef,
1716} from "@t3tools/contracts";
18import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
1917import {
2018 ArrowDownUpIcon,
2119 ArrowLeftIcon,
......@@ -63,20 +61,14 @@ import {
6361 type ShortcutMatchContext,
6462} from "~/keybindings";
6563import { primaryServerKeybindingsAtom } from "~/state/server";
66import { useClientSettings } from "~/hooks/useSettings";
67import {
68 deriveLogicalProjectKeyFromSettings,
69 derivePhysicalProjectKey,
70 selectProjectGroupingSettings,
71} from "~/logicalProject";
64import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions";
7265import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
7366import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
7467import { cn } from "~/lib/utils";
7568import { readLocalApi } from "~/localApi";
7669import type { ReviewCommentContext } from "~/reviewCommentContext";
77import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
7870import { useProjects, useServerConfigs } from "~/state/entities";
79import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
71import { useEnvironments } from "~/state/environments";
8072import { useEnvironmentQuery } from "~/state/query";
8173import { useLiveRefresh } from "~/hooks/useLiveRefresh";
8274import {
......@@ -563,19 +555,14 @@ export function PullRequestDetailPanel({
563555 }, [condensed]);
564556 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
565557 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
566 // Server-side and per project, like every other project setting. The
567 // client-local per-project map from before still answers when the server
568 // has no value, so a choice made on an older release keeps applying until
569 // it is set (or reset) in Settings.
570 const legacyMergeMethodOverrides = useClientSettings(
571 (settings) => settings.pullRequestMergeMethodOverrides,
558 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
559 environmentId,
560 reference.projectId,
561 );
562 const projectDefaultMergeMethod = useMemo(
563 () => resolveProjectDefaultMergeMethod(),
564 [resolveProjectDefaultMergeMethod],
572565 );
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;
579566 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
580567 readonly pullRequestKey: string;
581568 readonly method: PullRequestMergeMethod;
......@@ -887,39 +874,12 @@ export function PullRequestDetailPanel({
887874 const [titleSaving, setTitleSaving] = useState(false);
888875 const newThread = useNewThreadHandler();
889876 const { environments } = useEnvironments();
890 const primaryEnvironmentId = usePrimaryEnvironmentId();
891877 const unavailableGitHubUrl = useMemo(() => {
892878 const identity = projects.find(
893879 (project) => project.id === reference.projectId && project.environmentId === environmentId,
894880 )?.repositoryIdentity;
895881 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
896882 }, [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 ]);
923883 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
924884 // the thread is already on one server's copy of the branch.
925885 const pickableEnvironments = useMemo(
......@@ -1421,7 +1381,7 @@ export function PullRequestDetailPanel({
14211381 const selectedMergeMethod = resolvePullRequestMergeMethod(
14221382 allowedMergeMethods,
14231383 currentMergeMethod,
1424 projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod,
1384 projectDefaultMergeMethod,
14251385 lastSelectedMergeMethod,
14261386 );
14271387 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
apps/web/src/components/pullRequest/PullRequestRow.tsx+140-127
......@@ -1,5 +1,9 @@
11import { SearchIcon } from "lucide-react";
22import { PullRequestStackPopover } from "./PullRequestStackPopover";
3import {
4 PullRequestSpeedActions,
5 type PullRequestSpeedActionResult,
6} from "./PullRequestSpeedActions";
37import { memo, type RefCallback } from "react";
48
59import { cn } from "~/lib/utils";
......@@ -66,10 +70,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[
6670
6771/**
6872 * 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 the
70 * two lines without the padding: a 56px row less 20px of `py-2.5`.
73 * a narrow column. Its outer wrapper reserves the full row height when offscreen.
7174 */
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
7477export type PullRequestRowTarget = Pick<
7578 EnvironmentPullRequestEntry,
......@@ -86,6 +89,8 @@ function PullRequestRowImpl({
8689 statsKey,
8790 statsRef,
8891 onSelect,
92 speedMode,
93 onActed,
8994}: {
9095 entry: EnvironmentPullRequestEntry;
9196 selected: boolean;
......@@ -101,144 +106,152 @@ function PullRequestRowImpl({
101106 matchedElsewhere?: boolean;
102107 /** Used by the list's shared visibility observer to defer optional line-count reads. */
103108 statsKey?: string;
104 statsRef?: RefCallback<HTMLButtonElement>;
109 statsRef?: RefCallback<HTMLDivElement>;
105110 onSelect: (entry: PullRequestRowTarget) => void;
111 speedMode: boolean;
112 onActed: (result: PullRequestSpeedActionResult) => void;
106113}) {
107114 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
108115 return (
109 <button
116 <div
110117 ref={statsRef}
111118 data-pull-request-stats-key={statsKey}
112 type="button"
113 aria-current={selected ? "true" : undefined}
114 onClick={() => onSelect(entry)}
115119 className={cn(
116 PULL_REQUEST_ROW_CLASS,
117 PAGE_ROW_CLASS,
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]",
120 // Keep the action buttons inside the same skipped row, but outside its selection button.
121 "flex items-center rounded-md [content-visibility:auto] [contain-intrinsic-block-size:56.5px]",
123122 selected ? "bg-accent" : "hover:bg-accent/60",
124123 )}
125124 >
126 <PullRequestRowGlyph
127 state={entry.state}
128 isDraft={entry.isDraft}
129 mergeability={entry.mergeability}
130 baseBranch={entry.baseBranch}
131 // On the title line rather than between the lines, as main aligns it.
132 className="mt-0.75 self-start"
133 />
134 <PullRequestRowLines
135 number={
136 // The number carries the link, here as much as on the detail: a right-click on it
137 // copies the pull request's own address rather than opening the editing menu.
138 <span
139 className={PULL_REQUEST_ROW_NUMBER_CLASS}
140 onContextMenu={(event) => {
141 event.preventDefault();
142 event.stopPropagation();
143 void showPullRequestLinkContextMenu({
144 url: entry.url,
145 openLabel: openOnHostLabel(entry.provider),
146 position: { x: event.clientX, y: event.clientY },
147 });
148 }}
149 >
150 #{entry.number}
151 </span>
152 }
153 title={entry.title}
154 signals={
155 <>
156 {entry.checksState === undefined ? null : (
157 <PullRequestChecksPopover
158 checksState={entry.checksState}
159 environmentId={entry.environmentId}
160 reference={{
161 projectId: entry.projectId,
162 repository: entry.repository,
163 number: entry.number,
164 }}
165 />
166 )}
167 {/* Only a verdict the host actually reports: an approval, a request for changes,
125 <button
126 type="button"
127 aria-current={selected ? "true" : undefined}
128 onClick={() => onSelect(entry)}
129 className={cn(
130 PULL_REQUEST_ROW_CLASS,
131 PAGE_ROW_CLASS,
132 "min-w-0 flex-1 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
133 )}
134 >
135 <PullRequestRowGlyph
136 state={entry.state}
137 isDraft={entry.isDraft}
138 mergeability={entry.mergeability}
139 baseBranch={entry.baseBranch}
140 // On the title line rather than between the lines, as main aligns it.
141 className="mt-0.75 self-start"
142 />
143 <PullRequestRowLines
144 number={
145 // The number carries the link, here as much as on the detail: a right-click on it
146 // copies the pull request's own address rather than opening the editing menu.
147 <span
148 className={PULL_REQUEST_ROW_NUMBER_CLASS}
149 onContextMenu={(event) => {
150 event.preventDefault();
151 event.stopPropagation();
152 void showPullRequestLinkContextMenu({
153 url: entry.url,
154 openLabel: openOnHostLabel(entry.provider),
155 position: { x: event.clientX, y: event.clientY },
156 });
157 }}
158 >
159 #{entry.number}
160 </span>
161 }
162 title={entry.title}
163 signals={
164 <>
165 {entry.checksState === undefined ? null : (
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,
168177 or a review the branch rules still require. No glyph on the common case of a
169178 pull request nobody has reviewed, so a row only wears a person when the person
170179 has said something. */}
171 {entry.reviewDecision === undefined ? null : (
172 <PullRequestReviewDecisionGlyph decision={entry.reviewDecision} />
173 )}
174 </>
175 }
176 status={
177 <>
178 {entry.stack ? (
179 <PullRequestStackPopover
180 environmentId={entry.environmentId}
181 reference={{
182 projectId: entry.projectId,
183 host: entry.host,
184 repository: entry.repository,
185 number: entry.number,
186 }}
187 membership={entry.stack}
188 onSelect={(target) =>
189 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" />
180 {entry.reviewDecision === undefined ? null : (
181 <PullRequestReviewDecisionGlyph decision={entry.reviewDecision} />
182 )}
183 </>
184 }
185 status={
186 <>
187 {entry.stack ? (
188 <PullRequestStackPopover
189 environmentId={entry.environmentId}
190 reference={{
191 projectId: entry.projectId,
192 host: entry.host,
193 repository: entry.repository,
194 number: entry.number,
195 }}
196 membership={entry.stack}
197 onSelect={(target) =>
198 onSelect({ ...target, host: entry.host, environmentId: entry.environmentId })
208199 }
209 >
210 <span className="sr-only">matched in the description</span>
211 <SearchIcon aria-hidden className="size-3 shrink-0" />
212 <span aria-hidden className="hidden truncate @xs/pr-row-meta:block">
213 matched in the description
214 </span>
215 </TooltipTrigger>
216 <TooltipPopup side="top">Matched in the description</TooltipPopup>
217 </Tooltip>
218 ) : null}
219 {showProvider ? (
220 <Tooltip>
221 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
222 <Icon aria-label={providerName} className="size-3" />
223 </TooltipTrigger>
224 <TooltipPopup>{providerName}</TooltipPopup>
225 </Tooltip>
226 ) : null}
227 <PullRequestRowAuthor
228 actor={entry.author}
229 className="min-w-3.5 max-w-40"
230 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"
231 />
232 {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null}
233 {environmentLabel ? (
234 <span className="min-w-0 max-w-32 truncate">{environmentLabel}</span>
235 ) : null}
236 {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null}
237 </>
238 }
239 updatedAt={entry.updatedAt}
240 />
241 </button>
200 />
201 ) : null}
202 <PullRequestDiffStat
203 additions={entry.additions}
204 deletions={entry.deletions}
205 className="font-mono"
206 />
207 </>
208 }
209 metaClassName="@container/pr-row-meta"
210 meta={
211 <>
212 {matchedElsewhere ? (
213 <Tooltip>
214 <TooltipTrigger
215 render={
216 <span className="flex min-w-6 items-center gap-1 overflow-hidden rounded-full border border-border/60 px-1 text-3xs" />
217 }
218 >
219 <span className="sr-only">matched in the description</span>
220 <SearchIcon aria-hidden className="size-3 shrink-0" />
221 <span aria-hidden className="hidden truncate @xs/pr-row-meta:block">
222 matched in the description
223 </span>
224 </TooltipTrigger>
225 <TooltipPopup side="top">Matched in the description</TooltipPopup>
226 </Tooltip>
227 ) : null}
228 {showProvider ? (
229 <Tooltip>
230 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
231 <Icon aria-label={providerName} className="size-3" />
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>
242255 );
243256}
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 {
102102 showProjectTitle: false,
103103 showProvider: false,
104104 onSelect: () => {},
105 speedMode: false,
106 onActed: () => {},
105107 });
106108}
107109
apps/web/src/components/pullRequest/usePullRequestActions.ts+72-17
......@@ -8,12 +8,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment";
88import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
99import type {
1010 EnvironmentId,
11 ProjectId,
1112 PullRequestAction,
1213 PullRequestDetail,
1314 PullRequestMergeMethod,
1415 PullRequestRef,
1516} 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
1829import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
1930import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
......@@ -25,8 +36,48 @@ import { useAtomCommand } from "~/state/use-atom-command";
2536import { toastManager } from "../ui/toast";
2637import { 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
2879const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
29 merge: "Pull request merged",
80 merge: "Merge requested",
3081 ready: "Marked ready for review",
3182 draft: "Converted to draft",
3283 close: "Pull request closed",
......@@ -80,37 +131,41 @@ export function usePullRequestActionRunner({
80131 environmentId,
81132 reference,
82133 onSuccess,
134 resolveMergeMethod,
83135}: {
84136 environmentId: EnvironmentId;
85137 reference: PullRequestRef | null;
86138 onSuccess?: (action: PullRequestAction) => void;
139 /** Small surfaces resolve repository settings on the click, not for every visible row. */
140 resolveMergeMethod?: () => Promise<PullRequestMergeMethod>;
87141}) {
88142 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
89143 const [actionPending, setActionPending] = useState(false);
144 const pendingRef = useRef(false);
90145
91146 const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {
92 if (actionPending || reference === null) return;
147 if (pendingRef.current || reference === null) return;
148 pendingRef.current = true;
93149 setActionPending(true);
94 const result = await runAction({
95 environmentId,
96 input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) },
97 });
98 setActionPending(false);
99 if (result._tag === "Failure") {
100 // The host's own sentence, because it is the only thing that says why. A merge strategy a
101 // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps
102 // publishes no per-strategy availability to hide the control with — so "action failed"
103 // would leave the reader pressing the same button again.
104 const failure = squashAtomCommandFailure(result);
150 try {
151 const mergeMethod = method ?? (action === "merge" ? await resolveMergeMethod?.() : undefined);
152 const result = await runAction({
153 environmentId,
154 input: { ...reference, action, ...(mergeMethod ? { mergeMethod } : {}) },
155 });
156 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
157 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
158 onSuccess?.(action);
159 } catch (failure) {
105160 toastManager.add({
106161 type: "error",
107162 title: ACTION_FAILURE_LABELS[action],
108163 description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),
109164 });
110 return;
165 } finally {
166 pendingRef.current = false;
167 setActionPending(false);
111168 }
112 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
113 onSuccess?.(action);
114169 };
115170
116171 return { actionPending, perform };
apps/web/src/routes/_chat.pull-requests.tsx+19-2
......@@ -1,5 +1,7 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { Spinner } from "~/components/ui/spinner";
3import { useShortcutModifierState } from "~/shortcutModifierState";
4import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions";
35import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
46import type {
57 EnvironmentId,
......@@ -342,6 +344,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({
342344
343345function PullRequestsRouteView() {
344346 useEscapeToGoBack();
347 const modifiers = useShortcutModifierState(true);
348 const speedMode =
349 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
345350 const search = Route.useSearch();
346351 const sort = search.sort ?? "ready";
347352 const statsPolicy: PullRequestStatsPolicy =
......@@ -952,6 +957,16 @@ function PullRequestsRouteView() {
952957 };
953958 /** The detail panel's own writes, by row, so its failure takes back its own note. */
954959 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 }, []);
955970 // A reload recreates the registry the queries live in, so with nothing held the page would
956971 // cold-start into skeletons even though almost every row is unchanged. The last answer for
957972 // this set of environments is kept across reloads and hydrated here as the carried rows: they
......@@ -1395,11 +1410,11 @@ function PullRequestsRouteView() {
13951410 [statsBatches],
13961411 );
13971412 const statsObserver = useRef<IntersectionObserver | null>(null);
1398 const statsRows = useRef(new Set<HTMLButtonElement>());
1413 const statsRows = useRef(new Set<HTMLDivElement>());
13991414 const statsPending = useRef(true);
14001415 const statsPolicyRef = useRef(statsPolicy);
14011416 statsPolicyRef.current = statsPolicy;
1402 const registerStatsRow = useCallback((node: HTMLButtonElement | null) => {
1417 const registerStatsRow = useCallback((node: HTMLDivElement | null) => {
14031418 if (node === null || typeof IntersectionObserver === "undefined") return;
14041419 statsRows.current.add(node);
14051420 statsObserver.current?.observe(node);
......@@ -1817,6 +1832,8 @@ function PullRequestsRouteView() {
18171832 selected.number === entry.number
18181833 }
18191834 onSelect={selectEntry}
1835 speedMode={speedMode}
1836 onActed={onSpeedAction}
18201837 />
18211838 );
18221839 })}
apps/web/src/shortcutModifierState.ts+13-3
......@@ -1,4 +1,5 @@
11import { useEffect, useRef, useState } from "react";
2import { isEditableFocused } from "./lib/editableFocus";
23
34export interface ShortcutModifierState {
45 metaKey: boolean;
......@@ -26,7 +27,7 @@ export function areShortcutModifierStatesEqual(
2627 );
2728}
2829
29export function useShortcutModifierState(): ShortcutModifierState {
30export function useShortcutModifierState(ignoreEditable = false): ShortcutModifierState {
3031 const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE);
3132 const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE);
3233
......@@ -39,7 +40,11 @@ export function useShortcutModifierState(): ShortcutModifierState {
3940 setState(next);
4041 };
4142 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 );
4348 };
4449 // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup
4550 // never reaches the page, so the tracked state stays "⌘ held" forever and
......@@ -50,17 +55,22 @@ export function useShortcutModifierState(): ShortcutModifierState {
5055 updateState(EMPTY_SHORTCUT_MODIFIER_STATE);
5156 };
5257
58 const onFocus = (event: FocusEvent) => {
59 if (ignoreEditable && isEditableFocused(event.target)) onResetEvent();
60 };
61 window.addEventListener("focusin", onFocus);
5362 window.addEventListener("keydown", onKeyboardEvent, true);
5463 window.addEventListener("keyup", onKeyboardEvent, true);
5564 window.addEventListener("paste", onResetEvent, true);
5665 window.addEventListener("blur", onResetEvent);
5766 return () => {
67 window.removeEventListener("focusin", onFocus);
5868 window.removeEventListener("keydown", onKeyboardEvent, true);
5969 window.removeEventListener("keyup", onKeyboardEvent, true);
6070 window.removeEventListener("paste", onResetEvent, true);
6171 window.removeEventListener("blur", onResetEvent);
6272 };
63 }, []);
73 }, [ignoreEditable]);
6474
6575 return state;
6676}