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