| author | |
| committer | |
| log | 33b7d7b3ae2a7b1c3d1bd58c04cb9bf581ec2fe7 |
| tree | 64a76552a3ffb54f4b43f9887bc81db9333cc444 |
| parent | eac52f0087d9ba5dee5542f24788d1482affae43 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 32 | usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), |
| 33 | 33 | })); |
| 34 | 34 | vi.mock("~/hooks/useSettings", () => ({ |
| 35 | useEnvironmentSettings: () => undefined, | |
| 35 | 36 | useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => |
| 36 | 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 | 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 7 | import { |
| 8 | 8 | type EnvironmentId, |
| 9 | DEFAULT_SERVER_SETTINGS, | |
| 10 | 9 | type PullRequestAction, |
| 11 | 10 | type PullRequestMergeMethod, |
| 12 | 11 | type PullRequestListEntry, |
| ... | ... | @@ -15,7 +14,6 @@ import { |
| 15 | 14 | resolveEnvironmentMachineKind, |
| 16 | 15 | type ScopedThreadRef, |
| 17 | 16 | } from "@t3tools/contracts"; |
| 18 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 19 | 17 | import { |
| 20 | 18 | ArrowDownUpIcon, |
| 21 | 19 | ArrowLeftIcon, |
| ... | ... | @@ -63,20 +61,14 @@ import { |
| 63 | 61 | type ShortcutMatchContext, |
| 64 | 62 | } from "~/keybindings"; |
| 65 | 63 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 66 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 67 | import { | |
| 68 | deriveLogicalProjectKeyFromSettings, | |
| 69 | derivePhysicalProjectKey, | |
| 70 | selectProjectGroupingSettings, | |
| 71 | } from "~/logicalProject"; | |
| 64 | import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; | |
| 72 | 65 | import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; |
| 73 | 66 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 74 | 67 | import { cn } from "~/lib/utils"; |
| 75 | 68 | import { readLocalApi } from "~/localApi"; |
| 76 | 69 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 77 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; | |
| 78 | 70 | import { useProjects, useServerConfigs } from "~/state/entities"; |
| 79 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | |
| 71 | import { useEnvironments } from "~/state/environments"; | |
| 80 | 72 | import { useEnvironmentQuery } from "~/state/query"; |
| 81 | 73 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 82 | 74 | import { |
| ... | ... | @@ -563,19 +555,14 @@ export function PullRequestDetailPanel({ |
| 563 | 555 | }, [condensed]); |
| 564 | 556 | const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); |
| 565 | 557 | 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], | |
| 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 | 566 | const [mergeMethodSelection, setMergeMethodSelection] = useState<{ |
| 580 | 567 | readonly pullRequestKey: string; |
| 581 | 568 | readonly method: PullRequestMergeMethod; |
| ... | ... | @@ -887,39 +874,12 @@ export function PullRequestDetailPanel({ |
| 887 | 874 | const [titleSaving, setTitleSaving] = useState(false); |
| 888 | 875 | const newThread = useNewThreadHandler(); |
| 889 | 876 | const { environments } = useEnvironments(); |
| 890 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 891 | 877 | const unavailableGitHubUrl = useMemo(() => { |
| 892 | 878 | const identity = projects.find( |
| 893 | 879 | (project) => project.id === reference.projectId && project.environmentId === environmentId, |
| 894 | 880 | )?.repositoryIdentity; |
| 895 | 881 | return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); |
| 896 | 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 | 883 | // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and |
| 924 | 884 | // the thread is already on one server's copy of the branch. |
| 925 | 885 | const pickableEnvironments = useMemo( |
| ... | ... | @@ -1421,7 +1381,7 @@ export function PullRequestDetailPanel({ |
| 1421 | 1381 | const selectedMergeMethod = resolvePullRequestMergeMethod( |
| 1422 | 1382 | allowedMergeMethods, |
| 1423 | 1383 | currentMergeMethod, |
| 1424 | projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod, | |
| 1384 | projectDefaultMergeMethod, | |
| 1425 | 1385 | lastSelectedMergeMethod, |
| 1426 | 1386 | ); |
| 1427 | 1387 | const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; |
apps/web/src/components/pullRequest/PullRequestRow.tsx+140-127| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { SearchIcon } from "lucide-react"; |
| 2 | 2 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; |
| 3 | import { | |
| 4 | PullRequestSpeedActions, | |
| 5 | type PullRequestSpeedActionResult, | |
| 6 | } from "./PullRequestSpeedActions"; | |
| 3 | 7 | import { memo, type RefCallback } from "react"; |
| 4 | 8 | |
| 5 | 9 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -66,10 +70,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ |
| 66 | 70 | |
| 67 | 71 | /** |
| 68 | 72 | * 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. | |
| 71 | 74 | */ |
| 72 | const PAGE_ROW_CLASS = "px-3 py-2.5 [contain-intrinsic-block-size:36.5px]"; | |
| 75 | const PAGE_ROW_CLASS = "px-3 py-2.5"; | |
| 73 | 76 | |
| 74 | 77 | export type PullRequestRowTarget = Pick< |
| 75 | 78 | EnvironmentPullRequestEntry, |
| ... | ... | @@ -86,6 +89,8 @@ function PullRequestRowImpl({ |
| 86 | 89 | statsKey, |
| 87 | 90 | statsRef, |
| 88 | 91 | onSelect, |
| 92 | speedMode, | |
| 93 | onActed, | |
| 89 | 94 | }: { |
| 90 | 95 | entry: EnvironmentPullRequestEntry; |
| 91 | 96 | selected: boolean; |
| ... | ... | @@ -101,144 +106,152 @@ function PullRequestRowImpl({ |
| 101 | 106 | matchedElsewhere?: boolean; |
| 102 | 107 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ |
| 103 | 108 | statsKey?: string; |
| 104 | statsRef?: RefCallback<HTMLButtonElement>; | |
| 109 | statsRef?: RefCallback<HTMLDivElement>; | |
| 105 | 110 | onSelect: (entry: PullRequestRowTarget) => void; |
| 111 | speedMode: boolean; | |
| 112 | onActed: (result: PullRequestSpeedActionResult) => void; | |
| 106 | 113 | }) { |
| 107 | 114 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); |
| 108 | 115 | return ( |
| 109 | <button | |
| 116 | <div | |
| 110 | 117 | ref={statsRef} |
| 111 | 118 | data-pull-request-stats-key={statsKey} |
| 112 | type="button" | |
| 113 | aria-current={selected ? "true" : undefined} | |
| 114 | onClick={() => onSelect(entry)} | |
| 115 | 119 | 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]", | |
| 123 | 122 | selected ? "bg-accent" : "hover:bg-accent/60", |
| 124 | 123 | )} |
| 125 | 124 | > |
| 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, | |
| 168 | 177 | or a review the branch rules still require. No glyph on the common case of a |
| 169 | 178 | pull request nobody has reviewed, so a row only wears a person when the person |
| 170 | 179 | 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 }) | |
| 208 | 199 | } |
| 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> | |
| 242 | 255 | ); |
| 243 | 256 | } |
| 244 | 257 |
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | import type { PullRequestAction } from "@t3tools/contracts"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 5 | import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; | |
| 6 | import { useUiStateStore } from "~/uiStateStore"; | |
| 7 | import { Button } from "../ui/button"; | |
| 8 | import { Spinner } from "../ui/spinner"; | |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 10 | import { resolvePullRequestMergeMethod } from "./pullRequestDetail.logic"; | |
| 11 | import { PullRequestGlyph } from "./pullRequestIcons"; | |
| 12 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | |
| 13 | import { | |
| 14 | usePullRequestActionRunner, | |
| 15 | usePullRequestDefaultMergeMethodResolver, | |
| 16 | } from "./usePullRequestActions"; | |
| 17 | ||
| 18 | export 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. */ | |
| 24 | export 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 | ||
| 130 | const 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 | 102 | showProjectTitle: false, |
| 103 | 103 | showProvider: false, |
| 104 | 104 | onSelect: () => {}, |
| 105 | speedMode: false, | |
| 106 | onActed: () => {}, | |
| 105 | 107 | }); |
| 106 | 108 | } |
| 107 | 109 |
apps/web/src/components/pullRequest/usePullRequestActions.ts+72-17| ... | ... | @@ -8,12 +8,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 8 | 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 9 | import type { |
| 10 | 10 | EnvironmentId, |
| 11 | ProjectId, | |
| 11 | 12 | PullRequestAction, |
| 12 | 13 | PullRequestDetail, |
| 13 | 14 | PullRequestMergeMethod, |
| 14 | 15 | PullRequestRef, |
| 15 | 16 | } from "@t3tools/contracts"; |
| 16 | import { useState } from "react"; | |
| 17 | import { useCallback, useRef, useState } from "react"; | |
| 18 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 19 | import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings"; | |
| 20 | import { | |
| 21 | deriveLogicalProjectKeyFromSettings, | |
| 22 | derivePhysicalProjectKey, | |
| 23 | selectProjectGroupingSettings, | |
| 24 | } from "~/logicalProject"; | |
| 25 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; | |
| 26 | import { useProjects } from "~/state/entities"; | |
| 27 | import { usePrimaryEnvironmentId } from "~/state/environments"; | |
| 17 | 28 | |
| 18 | 29 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 19 | 30 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| ... | ... | @@ -25,8 +36,48 @@ import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | 36 | import { toastManager } from "../ui/toast"; |
| 26 | 37 | import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; |
| 27 | 38 | |
| 39 | /** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */ | |
| 40 | export 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 | ||
| 28 | 79 | const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { |
| 29 | merge: "Pull request merged", | |
| 80 | merge: "Merge requested", | |
| 30 | 81 | ready: "Marked ready for review", |
| 31 | 82 | draft: "Converted to draft", |
| 32 | 83 | close: "Pull request closed", |
| ... | ... | @@ -80,37 +131,41 @@ export function usePullRequestActionRunner({ |
| 80 | 131 | environmentId, |
| 81 | 132 | reference, |
| 82 | 133 | onSuccess, |
| 134 | resolveMergeMethod, | |
| 83 | 135 | }: { |
| 84 | 136 | environmentId: EnvironmentId; |
| 85 | 137 | reference: PullRequestRef | null; |
| 86 | 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 | 142 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); |
| 89 | 143 | const [actionPending, setActionPending] = useState(false); |
| 144 | const pendingRef = useRef(false); | |
| 90 | 145 | |
| 91 | 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 | 149 | 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) { | |
| 105 | 160 | toastManager.add({ |
| 106 | 161 | type: "error", |
| 107 | 162 | title: ACTION_FAILURE_LABELS[action], |
| 108 | 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 | }; |
| 115 | 170 | |
| 116 | 171 | return { actionPending, perform }; |
apps/web/src/routes/_chat.pull-requests.tsx+19-2| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 2 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | import { useShortcutModifierState } from "~/shortcutModifierState"; | |
| 4 | import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions"; | |
| 3 | 5 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 4 | 6 | import type { |
| 5 | 7 | EnvironmentId, |
| ... | ... | @@ -342,6 +344,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ |
| 342 | 344 | |
| 343 | 345 | function PullRequestsRouteView() { |
| 344 | 346 | useEscapeToGoBack(); |
| 347 | const modifiers = useShortcutModifierState(true); | |
| 348 | const speedMode = | |
| 349 | modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; | |
| 345 | 350 | const search = Route.useSearch(); |
| 346 | 351 | const sort = search.sort ?? "ready"; |
| 347 | 352 | const statsPolicy: PullRequestStatsPolicy = |
| ... | ... | @@ -952,6 +957,16 @@ function PullRequestsRouteView() { |
| 952 | 957 | }; |
| 953 | 958 | /** The detail panel's own writes, by row, so its failure takes back its own note. */ |
| 954 | 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 | 970 | // A reload recreates the registry the queries live in, so with nothing held the page would |
| 956 | 971 | // cold-start into skeletons even though almost every row is unchanged. The last answer for |
| 957 | 972 | // this set of environments is kept across reloads and hydrated here as the carried rows: they |
| ... | ... | @@ -1395,11 +1410,11 @@ function PullRequestsRouteView() { |
| 1395 | 1410 | [statsBatches], |
| 1396 | 1411 | ); |
| 1397 | 1412 | const statsObserver = useRef<IntersectionObserver | null>(null); |
| 1398 | const statsRows = useRef(new Set<HTMLButtonElement>()); | |
| 1413 | const statsRows = useRef(new Set<HTMLDivElement>()); | |
| 1399 | 1414 | const statsPending = useRef(true); |
| 1400 | 1415 | const statsPolicyRef = useRef(statsPolicy); |
| 1401 | 1416 | statsPolicyRef.current = statsPolicy; |
| 1402 | const registerStatsRow = useCallback((node: HTMLButtonElement | null) => { | |
| 1417 | const registerStatsRow = useCallback((node: HTMLDivElement | null) => { | |
| 1403 | 1418 | if (node === null || typeof IntersectionObserver === "undefined") return; |
| 1404 | 1419 | statsRows.current.add(node); |
| 1405 | 1420 | statsObserver.current?.observe(node); |
| ... | ... | @@ -1817,6 +1832,8 @@ function PullRequestsRouteView() { |
| 1817 | 1832 | selected.number === entry.number |
| 1818 | 1833 | } |
| 1819 | 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 | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { isEditableFocused } from "./lib/editableFocus"; | |
| 2 | 3 | |
| 3 | 4 | export interface ShortcutModifierState { |
| 4 | 5 | metaKey: boolean; |
| ... | ... | @@ -26,7 +27,7 @@ export function areShortcutModifierStatesEqual( |
| 26 | 27 | ); |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | export function useShortcutModifierState(): ShortcutModifierState { | |
| 30 | export function useShortcutModifierState(ignoreEditable = false): ShortcutModifierState { | |
| 30 | 31 | const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); |
| 31 | 32 | const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE); |
| 32 | 33 | |
| ... | ... | @@ -39,7 +40,11 @@ export function useShortcutModifierState(): ShortcutModifierState { |
| 39 | 40 | setState(next); |
| 40 | 41 | }; |
| 41 | 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 | 49 | // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup |
| 45 | 50 | // never reaches the page, so the tracked state stays "⌘ held" forever and |
| ... | ... | @@ -50,17 +55,22 @@ export function useShortcutModifierState(): ShortcutModifierState { |
| 50 | 55 | updateState(EMPTY_SHORTCUT_MODIFIER_STATE); |
| 51 | 56 | }; |
| 52 | 57 | |
| 58 | const onFocus = (event: FocusEvent) => { | |
| 59 | if (ignoreEditable && isEditableFocused(event.target)) onResetEvent(); | |
| 60 | }; | |
| 61 | window.addEventListener("focusin", onFocus); | |
| 53 | 62 | window.addEventListener("keydown", onKeyboardEvent, true); |
| 54 | 63 | window.addEventListener("keyup", onKeyboardEvent, true); |
| 55 | 64 | window.addEventListener("paste", onResetEvent, true); |
| 56 | 65 | window.addEventListener("blur", onResetEvent); |
| 57 | 66 | return () => { |
| 67 | window.removeEventListener("focusin", onFocus); | |
| 58 | 68 | window.removeEventListener("keydown", onKeyboardEvent, true); |
| 59 | 69 | window.removeEventListener("keyup", onKeyboardEvent, true); |
| 60 | 70 | window.removeEventListener("paste", onResetEvent, true); |
| 61 | 71 | window.removeEventListener("blur", onResetEvent); |
| 62 | 72 | }; |
| 63 | }, []); | |
| 73 | }, [ignoreEditable]); | |
| 64 | 74 | |
| 65 | 75 | return state; |
| 66 | 76 | } |