authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 21:00:50-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 21:00:50-03:00
logab28039ef77ca8847c6bfe845c3502ff7e348c30
tree696c0a14908d67f4b8c75856d56d805214a1c391
parentc5c6f9cb485b2db0f205797394bc95f9f575d139
signature Signed by PGP key B5690EEEBB952194

feat(web): add fast actions to linked pull requests (#16627)


2 files changed, 67 insertions(+), 14 deletions(-)

apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+13-8
...@@ -11,13 +11,18 @@ import {...@@ -11,13 +11,18 @@ import {
11 usePullRequestDefaultMergeMethodResolver,11 usePullRequestDefaultMergeMethodResolver,
12} from "./usePullRequestActions";12} from "./usePullRequestActions";
1313
14export interface PullRequestSpeedActionResult {14type PullRequestSpeedActionEntry = Pick<
15 readonly entry: EnvironmentPullRequestEntry;15 EnvironmentPullRequestEntry,
16 "environmentId" | "projectId" | "host" | "repository" | "number" | "state" | "isDraft"
17> & { readonly stack?: object | undefined };
18
19export interface PullRequestSpeedActionResult<Entry = EnvironmentPullRequestEntry> {
20 readonly entry: Entry;
16 readonly action: PullRequestAction;21 readonly action: PullRequestAction;
17}22}
1823
19/** No detail or stack reads until a merge is clicked, even on a long list. */24/** No detail or stack reads until a merge is clicked, even on a long list. */
20export function PullRequestSpeedActions({25export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({
21 entry,26 entry,
22 visible,27 visible,
23 onActed,28 onActed,
...@@ -25,12 +30,12 @@ export function PullRequestSpeedActions({...@@ -25,12 +30,12 @@ export function PullRequestSpeedActions({
25 sweeping = false,30 sweeping = false,
26 onCloseSweepStart,31 onCloseSweepStart,
27}: {32}: {
28 entry: EnvironmentPullRequestEntry;33 entry: Entry;
29 visible: boolean;34 visible: boolean;
30 onActed: (result: PullRequestSpeedActionResult) => void;35 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;
31 closing?: boolean;36 closing?: boolean;
32 sweeping?: boolean;37 sweeping?: boolean;
33 onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void;38 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;
34}) {39}) {
35 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(40 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
36 entry.environmentId,41 entry.environmentId,
...@@ -45,7 +50,7 @@ export function PullRequestSpeedActions({...@@ -45,7 +50,7 @@ export function PullRequestSpeedActions({
45 const { actionPending, perform } = usePullRequestActionRunner({50 const { actionPending, perform } = usePullRequestActionRunner({
46 environmentId: entry.environmentId,51 environmentId: entry.environmentId,
47 reference,52 reference,
48 onSuccess: (action) => onActed({ entry, action }),53 onSuccess: (action) => onActed?.({ entry, action }),
49 resolveMergeMethod: (detail) => {54 resolveMergeMethod: (detail) => {
50 const allowed = detail.capabilities.mergeMethods.filter(55 const allowed = detail.capabilities.mergeMethods.filter(
51 (method) => detail.mergeCapabilities[method],56 (method) => detail.mergeCapabilities[method],
...@@ -102,7 +107,7 @@ export function PullRequestSpeedActions({...@@ -102,7 +107,7 @@ export function PullRequestSpeedActions({
102 <TooltipPopup>107 <TooltipPopup>
103 {action === "merge" && entry.stack108 {action === "merge" && entry.stack
104 ? "Open this pull request to merge its stack"109 ? "Open this pull request to merge its stack"
105 : action === "close"110 : action === "close" && onCloseSweepStart
106 ? "Close immediately, or drag across rows to close several"111 ? "Close immediately, or drag across rows to close several"
107 : `${label} immediately`}112 : `${label} immediately`}
108 </TooltipPopup>113 </TooltipPopup>
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+54-6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
2import {3import {
3 resolveThreadPullRequestChains,4 resolveThreadPullRequestChains,
4 visibleThreadPullRequests,5 visibleThreadPullRequests,
...@@ -14,9 +15,10 @@ import {...@@ -14,9 +15,10 @@ import {
14import { useCallback, useMemo, useState } from "react";15import { useCallback, useMemo, useState } from "react";
1516
16import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";17import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
17import { useOpenPrLink } from "~/lib/openPullRequestLink";18import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink";
18import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
19import { useServerConfigs, useThreadShell } from "~/state/entities";20import { useShortcutModifierState } from "~/shortcutModifierState";
21import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities";
20import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";22import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
21import { threadEnvironment } from "~/state/threads";23import { threadEnvironment } from "~/state/threads";
22import { useAtomCommand } from "~/state/use-atom-command";24import { useAtomCommand } from "~/state/use-atom-command";
...@@ -42,6 +44,7 @@ import {...@@ -42,6 +44,7 @@ import {
42 pullRequestChecksStatePresentation,44 pullRequestChecksStatePresentation,
43} from "./pullRequestPresentation";45} from "./pullRequestPresentation";
44import { PullRequestGlyph } from "./pullRequestIcons";46import { PullRequestGlyph } from "./pullRequestIcons";
47import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
4548
46const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {49const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
47 manual: "Linked by you",50 manual: "Linked by you",
...@@ -74,11 +77,15 @@ function ChecksGlyph({...@@ -74,11 +77,15 @@ function ChecksGlyph({
74function LinkRow({77function LinkRow({
75 line,78 line,
76 threadRef,79 threadRef,
80 projectId,
81 speedMode,
77 onUnlink,82 onUnlink,
78 onSetWatching,83 onSetWatching,
79}: {84}: {
80 line: PullRequestListLine;85 line: PullRequestListLine;
81 threadRef: ScopedThreadRef;86 threadRef: ScopedThreadRef;
87 projectId: ProjectId | null;
88 speedMode: boolean;
82 onUnlink: (link: ThreadPullRequestLink) => void;89 onUnlink: (link: ThreadPullRequestLink) => void;
83 /** Null when the environment cannot watch pull requests. */90 /** Null when the environment cannot watch pull requests. */
84 onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null;91 onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null;
...@@ -97,6 +104,22 @@ function LinkRow({...@@ -97,6 +104,22 @@ function LinkRow({
97 const snapshot = link.snapshot;104 const snapshot = link.snapshot;
98 const open = snapshot === null || snapshot.state === "open";105 const open = snapshot === null || snapshot.state === "open";
99 const watching = link.watch !== undefined;106 const watching = link.watch !== undefined;
107 const actionEntry =
108 projectId !== null &&
109 snapshot !== null &&
110 snapshot.state !== "merged" &&
111 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github"
112 ? {
113 environmentId: threadRef.environmentId,
114 projectId,
115 host: link.host,
116 repository: link.repository,
117 number: link.number,
118 state: snapshot.state,
119 isDraft: snapshot.isDraft,
120 ...(link.stack === null ? {} : { stack: link.stack }),
121 }
122 : null;
100 return (123 return (
101 <div124 <div
102 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}125 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
...@@ -221,6 +244,9 @@ function LinkRow({...@@ -221,6 +244,9 @@ function LinkRow({
221 updatedAt={snapshot?.updatedAt}244 updatedAt={snapshot?.updatedAt}
222 />245 />
223 </a>246 </a>
247 {actionEntry !== null ? (
248 <PullRequestSpeedActions entry={actionEntry} visible={speedMode} />
249 ) : null}
224 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one250 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one
225 shows. It sits over the right end of the second line on the row's own hover color,251 shows. It sits over the right end of the second line on the row's own hover color,
226 fading in from the left, so it covers the time and leaves the diff counts alone. */}252 fading in from the left, so it covers the time and leaves the diff counts alone. */}
...@@ -233,6 +259,8 @@ function LinkRow({...@@ -233,6 +259,8 @@ function LinkRow({
233 "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100",259 "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100",
234 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",260 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",
235 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",261 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",
262 "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden",
263 speedMode && actionEntry !== null && "hidden",
236 )}264 )}
237 >265 >
238 <span aria-hidden className="absolute inset-0 bg-accent/60" />266 <span aria-hidden className="absolute inset-0 bg-accent/60" />
...@@ -301,12 +329,24 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -301,12 +329,24 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
301329
302function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {330function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
303 const thread = useThreadShell(threadRef);331 const thread = useThreadShell(threadRef);
332 const projects = useProjects();
333 const environmentProjects = useMemo(
334 () =>
335 projects
336 .filter((project) => project.environmentId === threadRef.environmentId)
337 .toSorted((left, right) =>
338 left.id === thread?.projectId ? -1 : right.id === thread?.projectId ? 1 : 0,
339 ),
340 [projects, threadRef.environmentId, thread?.projectId],
341 );
342 const modifiers = useShortcutModifierState(true);
343 const speedMode =
344 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
304 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);345 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
305 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });346 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
306 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });347 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
307 const supportsWatch =348 const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities;
308 useServerConfigs().get(threadRef.environmentId)?.environment.capabilities349 const supportsWatch = capabilities?.threadPullRequestWatch === true;
309 .threadPullRequestWatch === true;
310 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);350 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
311 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);351 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
312 const handleUnlink = useCallback(352 const handleUnlink = useCallback(
...@@ -377,6 +417,14 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -377,6 +417,14 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
377 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}417 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
378 line={line}418 line={line}
379 threadRef={threadRef}419 threadRef={threadRef}
420 projectId={
421 capabilities?.pullRequests === true
422 ? (findProjectForChangeRequest(environmentProjects, line.link)?.id ??
423 thread?.projectId ??
424 null)
425 : null
426 }
427 speedMode={speedMode}
380 onUnlink={handleUnlink}428 onUnlink={handleUnlink}
381 onSetWatching={supportsWatch ? handleSetWatching : null}429 onSetWatching={supportsWatch ? handleSetWatching : null}
382 />430 />