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 {
1111 usePullRequestDefaultMergeMethodResolver,
1212} from "./usePullRequestActions";
1313
14export interface PullRequestSpeedActionResult {
15 readonly entry: EnvironmentPullRequestEntry;
14type PullRequestSpeedActionEntry = Pick<
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;
1621 readonly action: PullRequestAction;
1722}
1823
1924/** No detail or stack reads until a merge is clicked, even on a long list. */
20export function PullRequestSpeedActions({
25export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntry>({
2126 entry,
2227 visible,
2328 onActed,
......@@ -25,12 +30,12 @@ export function PullRequestSpeedActions({
2530 sweeping = false,
2631 onCloseSweepStart,
2732}: {
28 entry: EnvironmentPullRequestEntry;
33 entry: Entry;
2934 visible: boolean;
30 onActed: (result: PullRequestSpeedActionResult) => void;
35 onActed?: (result: PullRequestSpeedActionResult<Entry>) => void;
3136 closing?: boolean;
3237 sweeping?: boolean;
33 onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void;
38 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;
3439}) {
3540 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
3641 entry.environmentId,
......@@ -45,7 +50,7 @@ export function PullRequestSpeedActions({
4550 const { actionPending, perform } = usePullRequestActionRunner({
4651 environmentId: entry.environmentId,
4752 reference,
48 onSuccess: (action) => onActed({ entry, action }),
53 onSuccess: (action) => onActed?.({ entry, action }),
4954 resolveMergeMethod: (detail) => {
5055 const allowed = detail.capabilities.mergeMethods.filter(
5156 (method) => detail.mergeCapabilities[method],
......@@ -102,7 +107,7 @@ export function PullRequestSpeedActions({
102107 <TooltipPopup>
103108 {action === "merge" && entry.stack
104109 ? "Open this pull request to merge its stack"
105 : action === "close"
110 : action === "close" && onCloseSweepStart
106111 ? "Close immediately, or drag across rows to close several"
107112 : `${label} immediately`}
108113 </TooltipPopup>
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+54-6
......@@ -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";
23import {
34 resolveThreadPullRequestChains,
45 visibleThreadPullRequests,
......@@ -14,9 +15,10 @@ import {
1415import { useCallback, useMemo, useState } from "react";
1516
1617import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
17import { useOpenPrLink } from "~/lib/openPullRequestLink";
18import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink";
1819import { cn } from "~/lib/utils";
19import { useServerConfigs, useThreadShell } from "~/state/entities";
20import { useShortcutModifierState } from "~/shortcutModifierState";
21import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities";
2022import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
2123import { threadEnvironment } from "~/state/threads";
2224import { useAtomCommand } from "~/state/use-atom-command";
......@@ -42,6 +44,7 @@ import {
4244 pullRequestChecksStatePresentation,
4345} from "./pullRequestPresentation";
4446import { PullRequestGlyph } from "./pullRequestIcons";
47import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
4548
4649const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
4750 manual: "Linked by you",
......@@ -74,11 +77,15 @@ function ChecksGlyph({
7477function LinkRow({
7578 line,
7679 threadRef,
80 projectId,
81 speedMode,
7782 onUnlink,
7883 onSetWatching,
7984}: {
8085 line: PullRequestListLine;
8186 threadRef: ScopedThreadRef;
87 projectId: ProjectId | null;
88 speedMode: boolean;
8289 onUnlink: (link: ThreadPullRequestLink) => void;
8390 /** Null when the environment cannot watch pull requests. */
8491 onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null;
......@@ -97,6 +104,22 @@ function LinkRow({
97104 const snapshot = link.snapshot;
98105 const open = snapshot === null || snapshot.state === "open";
99106 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;
100123 return (
101124 <div
102125 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
......@@ -221,6 +244,9 @@ function LinkRow({
221244 updatedAt={snapshot?.updatedAt}
222245 />
223246 </a>
247 {actionEntry !== null ? (
248 <PullRequestSpeedActions entry={actionEntry} visible={speedMode} />
249 ) : null}
224250 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one
225251 shows. It sits over the right end of the second line on the row's own hover color,
226252 fading in from the left, so it covers the time and leaves the diff counts alone. */}
......@@ -233,6 +259,8 @@ function LinkRow({
233259 "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100",
234260 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",
235261 "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",
236264 )}
237265 >
238266 <span aria-hidden className="absolute inset-0 bg-accent/60" />
......@@ -301,12 +329,24 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
301329
302330function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
303331 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;
304345 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
305346 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
306347 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
307 const supportsWatch =
308 useServerConfigs().get(threadRef.environmentId)?.environment.capabilities
309 .threadPullRequestWatch === true;
348 const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities;
349 const supportsWatch = capabilities?.threadPullRequestWatch === true;
310350 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
311351 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
312352 const handleUnlink = useCallback(
......@@ -377,6 +417,14 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
377417 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
378418 line={line}
379419 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}
380428 onUnlink={handleUnlink}
381429 onSetWatching={supportsWatch ? handleSetWatching : null}
382430 />