| 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | commitMessageToEditor, |
| 4 | editorToCommitMessage, |
| 5 | type EnvironmentId, |
| 6 | type JjChange, |
| 7 | type JjStack, |
| 8 | type MergebotPullRequestLiveStatus, |
| 9 | type ScopedThreadRef, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill"; |
| 12 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| 13 | import { |
| 14 | ArrowUpIcon, |
| 15 | ChevronDownIcon, |
| 16 | Columns2Icon, |
| 17 | ExternalLinkIcon, |
| 18 | Rows2Icon, |
| 19 | } from "lucide-react"; |
| 20 | import { |
| 21 | useCallback, |
| 22 | useEffect, |
| 23 | useLayoutEffect, |
| 24 | useMemo, |
| 25 | useRef, |
| 26 | useState, |
| 27 | type ReactNode, |
| 28 | } from "react"; |
| 29 | |
| 30 | import type { DraftId } from "~/composerDraftStore"; |
| 31 | import { useTheme } from "~/hooks/useTheme"; |
| 32 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; |
| 33 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; |
| 34 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 35 | import { |
| 36 | buildFileDiffIdentityKey, |
| 37 | getRenderablePatch, |
| 38 | resolveDiffThemeName, |
| 39 | resolveFileDiffPath, |
| 40 | } from "~/lib/diffRendering"; |
| 41 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 42 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 43 | import { useThreadShell } from "~/state/entities"; |
| 44 | import { jjEnvironment } from "~/state/jj"; |
| 45 | import { |
| 46 | mergebotEnvironment, |
| 47 | useMergebotPullRequestStatus, |
| 48 | useMergebotQueue, |
| 49 | } from "~/state/mergebot"; |
| 50 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 51 | import * as Schema from "effect/Schema"; |
| 52 | import * as Atom from "effect/reactivity/Atom"; |
| 53 | import * as AsyncResult from "effect/reactivity/AsyncResult"; |
| 54 | |
| 55 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell"; |
| 56 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 57 | import { AnnotatableCodeView } from "../diffs/AnnotatableCodeView"; |
| 58 | import { Button } from "../ui/button"; |
| 59 | import { Group, GroupSeparator } from "../ui/group"; |
| 60 | import { Menu, MenuItem, MenuItemLabel, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 61 | import { CommitMessageEditor } from "./CommitMessageEditor"; |
| 62 | import { CommitTitle } from "./commitTitle"; |
| 63 | import { MergebotStatusPill } from "./MergebotStatusPill"; |
| 64 | import { StackRail, StackStrip } from "./JjStackViews"; |
| 65 | import { |
| 66 | changeTitle, |
| 67 | clampSelection, |
| 68 | newestPullRequestIndex, |
| 69 | pullRequestFor, |
| 70 | pullRequestText, |
| 71 | selectChange, |
| 72 | selectionRange, |
| 73 | visibleChanges, |
| 74 | type Selection, |
| 75 | } from "./jjReview.logic"; |
| 76 | |
| 77 | const SAVE_DELAY_MS = 800; |
| 78 | const NO_DIFF = Atom.make(AsyncResult.initial()); |
| 79 | |
| 80 | export function JjReviewPanel(props: { |
| 81 | mode: DiffPanelMode; |
| 82 | environmentId: EnvironmentId; |
| 83 | threadRef: ScopedThreadRef | null; |
| 84 | cwd: string; |
| 85 | stack: JjStack; |
| 86 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 87 | }) { |
| 88 | const { environmentId, cwd, stack } = props; |
| 89 | const { resolvedTheme } = useTheme(); |
| 90 | const settings = useClientSettings(); |
| 91 | const updateSettings = useUpdateClientSettings(); |
| 92 | const [railOpen, setRailOpen] = useLocalStorage("t3.jjReview.railOpen", false, Schema.Boolean); |
| 93 | const changes = useMemo(() => visibleChanges(stack), [stack]); |
| 94 | const [rawSelection, setSelection] = useState<Selection>([0, 0]); |
| 95 | const [anchor, setAnchor] = useState(0); |
| 96 | const selection = clampSelection(rawSelection, changes.length); |
| 97 | |
| 98 | const snapshot = useAtomCommand(jjEnvironment.snapshot, { reportFailure: false }); |
| 99 | useEffect(() => { |
| 100 | // Reads never snapshot; opening or refocusing the panel picks up the agent's latest edits. |
| 101 | const refresh = () => void snapshot({ environmentId, input: { cwd } }); |
| 102 | refresh(); |
| 103 | window.addEventListener("focus", refresh); |
| 104 | return () => window.removeEventListener("focus", refresh); |
| 105 | }, [cwd, environmentId, snapshot]); |
| 106 | |
| 107 | const repository = stack.repository; |
| 108 | const queue = useMergebotQueue(environmentId, repository ?? ""); |
| 109 | const thread = useThreadShell(props.threadRef); |
| 110 | // Open PRs come from Mergebot's queue by head commit; a merged or closed PR this thread opened |
| 111 | // drops out of the queue, so its link still claims the change its bookmark points at. |
| 112 | const prByHeadSha = useMemo(() => { |
| 113 | const map = new Map( |
| 114 | (repository && queue?.pullRequests ? queue.pullRequests : []).map( |
| 115 | (pr) => [pr.headSha, pr.number] as const, |
| 116 | ), |
| 117 | ); |
| 118 | const linked = thread ? resolveThreadCurrentPullRequestLink(thread.pullRequests ?? []) : null; |
| 119 | const head = linked?.snapshot?.headBranch; |
| 120 | const claimed = head && changes.find((change) => change.bookmarks.some((b) => b.name === head)); |
| 121 | if (linked && claimed && ![...map.values()].includes(linked.number)) { |
| 122 | map.set(claimed.commitId, linked.number); |
| 123 | } |
| 124 | return map; |
| 125 | }, [changes, queue, repository, thread]); |
| 126 | const headIndex = newestPullRequestIndex(changes, prByHeadSha); |
| 127 | const pullRequestNumber = |
| 128 | headIndex >= 0 ? (prByHeadSha.get(changes[headIndex]!.commitId) ?? null) : null; |
| 129 | const pendingCount = headIndex > 0 ? headIndex : 0; |
| 130 | |
| 131 | const range = selectionRange(changes, selection); |
| 132 | const diffAtom = range |
| 133 | ? jjEnvironment.diff({ environmentId, input: { cwd, ...range } }) |
| 134 | : NO_DIFF; |
| 135 | const diffResult = useAtomValue(diffAtom); |
| 136 | const refreshDiff = useAtomRefresh(diffAtom); |
| 137 | const diff = diffResult._tag === "Success" ? diffResult.value : null; |
| 138 | const renderable = useMemo( |
| 139 | () => getRenderablePatch(diff?.diff, `jj:${diff?.diffHash ?? ""}`), |
| 140 | [diff?.diff, diff?.diffHash], |
| 141 | ); |
| 142 | const files = useMemo( |
| 143 | () => |
| 144 | renderable?.kind === "files" |
| 145 | ? renderable.files.map((fileDiff) => ({ |
| 146 | fileDiff, |
| 147 | filePath: resolveFileDiffPath(fileDiff), |
| 148 | fileKey: buildFileDiffIdentityKey(fileDiff), |
| 149 | fileVersion: 0, |
| 150 | collapsed: false, |
| 151 | })) |
| 152 | : [], |
| 153 | [renderable], |
| 154 | ); |
| 155 | |
| 156 | const select = (index: number, extend: boolean) => { |
| 157 | setSelection(selectChange(anchor, index, extend)); |
| 158 | if (!extend) setAnchor(index); |
| 159 | }; |
| 160 | const selectAll = () => { |
| 161 | setSelection([0, changes.length - 1]); |
| 162 | setAnchor(0); |
| 163 | }; |
| 164 | |
| 165 | const selectedPullRequest = (() => { |
| 166 | const numbers = new Set<number | null>(); |
| 167 | for (let index = selection[0]; index <= Math.min(selection[1], changes.length - 1); index++) { |
| 168 | numbers.add(pullRequestFor(changes, prByHeadSha, index)); |
| 169 | } |
| 170 | const [only] = numbers; |
| 171 | return numbers.size === 1 && only != null ? only : null; |
| 172 | })(); |
| 173 | const selectedChange = selection[0] === selection[1] ? changes[selection[0]] : undefined; |
| 174 | |
| 175 | const render = (status: MergebotPullRequestLiveStatus | null) => { |
| 176 | const pill = status ? mergebotStatusPill(status) : null; |
| 177 | const header = ( |
| 178 | <ReviewHeader |
| 179 | environmentId={environmentId} |
| 180 | threadRef={props.threadRef} |
| 181 | cwd={cwd} |
| 182 | repository={repository} |
| 183 | changes={changes} |
| 184 | pendingCount={pendingCount} |
| 185 | pullRequestNumber={pullRequestNumber} |
| 186 | status={status} |
| 187 | pill={pill} |
| 188 | diffLayout={settings.diffLayout} |
| 189 | onToggleLayout={() => |
| 190 | updateSettings({ diffLayout: settings.diffLayout === "split" ? "stacked" : "split" }) |
| 191 | } |
| 192 | /> |
| 193 | ); |
| 194 | |
| 195 | const editor = |
| 196 | selectedPullRequest !== null && status && selectedPullRequest === status.number ? ( |
| 197 | <PullRequestMessage |
| 198 | key={`pr:${status.number}`} |
| 199 | environmentId={environmentId} |
| 200 | repository={repository!} |
| 201 | number={status.number} |
| 202 | title={status.title} |
| 203 | body={status.body ?? ""} |
| 204 | /> |
| 205 | ) : selectedChange ? ( |
| 206 | <ChangeMessage |
| 207 | key={selectedChange.changeId} |
| 208 | environmentId={environmentId} |
| 209 | cwd={cwd} |
| 210 | change={selectedChange} |
| 211 | /> |
| 212 | ) : ( |
| 213 | <RangeSummary |
| 214 | changes={changes.slice(selection[0], selection[1] + 1)} |
| 215 | opensPullRequest={headIndex < 0} |
| 216 | /> |
| 217 | ); |
| 218 | |
| 219 | if (changes.length === 0) { |
| 220 | return ( |
| 221 | <DiffPanelShell mode={props.mode} header={header}> |
| 222 | <div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground"> |
| 223 | No changes off {stack.trunk?.name ?? "trunk"} yet. |
| 224 | </div> |
| 225 | </DiffPanelShell> |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | return ( |
| 230 | <DiffPanelShell mode={props.mode} header={header}> |
| 231 | <div className="flex min-h-0 flex-1"> |
| 232 | {railOpen ? ( |
| 233 | <StackRail |
| 234 | stack={stack} |
| 235 | changes={changes} |
| 236 | selection={selection} |
| 237 | pendingCount={pendingCount} |
| 238 | files={diff?.files ?? []} |
| 239 | onSelect={select} |
| 240 | onSelectAll={selectAll} |
| 241 | onClose={() => setRailOpen(false)} |
| 242 | onOpenFile={(path) => |
| 243 | document |
| 244 | .querySelector(`[data-jj-review] [data-file-path="${CSS.escape(path)}"]`) |
| 245 | ?.scrollIntoView({ block: "start" }) |
| 246 | } |
| 247 | /> |
| 248 | ) : null} |
| 249 | <div data-jj-review className="flex min-h-0 min-w-0 flex-1 flex-col"> |
| 250 | {railOpen ? null : ( |
| 251 | <StackStrip |
| 252 | stack={stack} |
| 253 | changes={changes} |
| 254 | selection={selection} |
| 255 | onSelect={select} |
| 256 | onSelectAll={selectAll} |
| 257 | onOpenRail={() => setRailOpen(true)} |
| 258 | /> |
| 259 | )} |
| 260 | {diffResult._tag === "Initial" ? ( |
| 261 | <div className="flex min-h-0 flex-1 flex-col overflow-auto"> |
| 262 | {editor} |
| 263 | <DiffPanelLoadingState label="Loading changes…" /> |
| 264 | </div> |
| 265 | ) : diffResult._tag === "Failure" ? ( |
| 266 | <div className="flex min-h-0 flex-1 flex-col overflow-auto"> |
| 267 | {editor} |
| 268 | <div role="alert" className="flex flex-col items-center gap-2 p-6"> |
| 269 | <p className="text-sm text-muted-foreground">Couldn’t load changes.</p> |
| 270 | <Button size="sm" variant="outline" onClick={refreshDiff}> |
| 271 | Try again |
| 272 | </Button> |
| 273 | </div> |
| 274 | </div> |
| 275 | ) : ( |
| 276 | <AnnotatableCodeView |
| 277 | codeViewKey={`jj:${diff?.diffHash ?? "none"}`} |
| 278 | className="h-full min-h-0 overflow-auto" |
| 279 | files={files} |
| 280 | sectionId={`jj:${range?.toCommitId ?? ""}`} |
| 281 | sectionTitle={selectedChange ? changeTitle(selectedChange) || "Changes" : "Changes"} |
| 282 | composerDraftTarget={props.composerDraftTarget} |
| 283 | renderCodeViewHeader={() => editor} |
| 284 | renderHeaderFilenameSuffix={() => null} |
| 285 | renderHeaderPrefix={() => null} |
| 286 | onRevealSearchMatch={() => {}} |
| 287 | options={{ |
| 288 | diffStyle: settings.diffLayout === "split" ? "split" : "unified", |
| 289 | lineDiffType: "none", |
| 290 | overflow: settings.wordWrap ? "wrap" : "scroll", |
| 291 | theme: resolveDiffThemeName(resolvedTheme), |
| 292 | preferredHighlighter: PREFERRED_HIGHLIGHTER, |
| 293 | themeType: resolvedTheme, |
| 294 | stickyHeaders: true, |
| 295 | }} |
| 296 | /> |
| 297 | )} |
| 298 | </div> |
| 299 | </div> |
| 300 | </DiffPanelShell> |
| 301 | ); |
| 302 | }; |
| 303 | |
| 304 | return pullRequestNumber !== null && repository ? ( |
| 305 | <PullRequestStatusScope |
| 306 | environmentId={environmentId} |
| 307 | repository={repository} |
| 308 | number={pullRequestNumber} |
| 309 | > |
| 310 | {render} |
| 311 | </PullRequestStatusScope> |
| 312 | ) : ( |
| 313 | render(null) |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | function PullRequestStatusScope(props: { |
| 318 | environmentId: EnvironmentId; |
| 319 | repository: string; |
| 320 | number: number; |
| 321 | children: (status: MergebotPullRequestLiveStatus | null) => ReactNode; |
| 322 | }) { |
| 323 | return props.children( |
| 324 | useMergebotPullRequestStatus(props.environmentId, props.repository, props.number), |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | /** A range has no single message to edit; it shows what it spans and what a PR would open as. */ |
| 329 | function RangeSummary(props: { changes: readonly JjChange[]; opensPullRequest: boolean }) { |
| 330 | const additions = props.changes.reduce((sum, change) => sum + change.additions, 0); |
| 331 | const deletions = props.changes.reduce((sum, change) => sum + change.deletions, 0); |
| 332 | const title = pullRequestText(props.changes).title; |
| 333 | return ( |
| 334 | <div className="border-b border-border/60 px-4 pt-3 pb-2.5"> |
| 335 | <div className="text-xs text-muted-foreground"> |
| 336 | {props.changes.length} changes ·{" "} |
| 337 | <span className="font-mono text-success-foreground">+{additions}</span>{" "} |
| 338 | <span className="font-mono text-error-foreground">−{deletions}</span> |
| 339 | {props.opensPullRequest && title ? " · opens as" : null} |
| 340 | </div> |
| 341 | {props.opensPullRequest && title ? ( |
| 342 | <div className="mt-1 truncate text-sm font-semibold"> |
| 343 | <CommitTitle title={title} /> |
| 344 | </div> |
| 345 | ) : null} |
| 346 | </div> |
| 347 | ); |
| 348 | } |
| 349 | |
| 350 | /** Saves the latest edit after a pause; flushes on unmount so switching changes keeps the edit. */ |
| 351 | function useDebouncedSave(save: (value: string) => void) { |
| 352 | const pending = useRef<string | null>(null); |
| 353 | const timer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 354 | const saveRef = useRef(save); |
| 355 | useLayoutEffect(() => { |
| 356 | saveRef.current = save; |
| 357 | }); |
| 358 | const flush = useCallback(() => { |
| 359 | if (timer.current) clearTimeout(timer.current); |
| 360 | timer.current = null; |
| 361 | if (pending.current !== null) saveRef.current(pending.current); |
| 362 | pending.current = null; |
| 363 | }, []); |
| 364 | useEffect(() => flush, [flush]); |
| 365 | return useCallback( |
| 366 | (value: string) => { |
| 367 | pending.current = value; |
| 368 | if (timer.current) clearTimeout(timer.current); |
| 369 | timer.current = setTimeout(flush, SAVE_DELAY_MS); |
| 370 | }, |
| 371 | [flush], |
| 372 | ); |
| 373 | } |
| 374 | |
| 375 | function ChangeMessage(props: { environmentId: EnvironmentId; cwd: string; change: JjChange }) { |
| 376 | const { change } = props; |
| 377 | const describe = useAtomCommand(jjEnvironment.describe, { reportFailure: true }); |
| 378 | const [value, setValue] = useState(() => commitMessageToEditor(change.description)); |
| 379 | const loadedCommitId = useRef(change.commitId); |
| 380 | const saved = useRef(change.description); |
| 381 | useEffect(() => { |
| 382 | // Another writer (the agent, a terminal) changed the description: show theirs. |
| 383 | if (change.description !== saved.current) { |
| 384 | saved.current = change.description; |
| 385 | setValue(commitMessageToEditor(change.description)); |
| 386 | } |
| 387 | loadedCommitId.current = change.commitId; |
| 388 | }, [change.commitId, change.description]); |
| 389 | const save = useDebouncedSave((text) => { |
| 390 | const message = editorToCommitMessage(text); |
| 391 | if (message === saved.current) return; |
| 392 | saved.current = message; |
| 393 | void describe({ |
| 394 | environmentId: props.environmentId, |
| 395 | input: { |
| 396 | cwd: props.cwd, |
| 397 | changeId: change.changeId, |
| 398 | expectedCommitId: loadedCommitId.current, |
| 399 | message, |
| 400 | }, |
| 401 | }); |
| 402 | }); |
| 403 | return ( |
| 404 | <div className="border-b border-border/60 pb-1"> |
| 405 | <CommitMessageEditor |
| 406 | value={value} |
| 407 | readOnly={change.immutable} |
| 408 | placeholder="type(scope): what changed" |
| 409 | onChange={(next) => { |
| 410 | setValue(next); |
| 411 | save(next); |
| 412 | }} |
| 413 | /> |
| 414 | </div> |
| 415 | ); |
| 416 | } |
| 417 | |
| 418 | function PullRequestMessage(props: { |
| 419 | environmentId: EnvironmentId; |
| 420 | repository: string; |
| 421 | number: number; |
| 422 | title: string; |
| 423 | body: string; |
| 424 | }) { |
| 425 | const update = useAtomCommand(mergebotEnvironment.updatePullRequest, { reportFailure: true }); |
| 426 | const message = props.body ? `${props.title}\n\n${props.body}` : props.title; |
| 427 | const [value, setValue] = useState(() => commitMessageToEditor(message)); |
| 428 | const save = useDebouncedSave((text) => { |
| 429 | const [title = "", ...rest] = editorToCommitMessage(text).split("\n"); |
| 430 | if (!title.trim()) return; |
| 431 | void update({ |
| 432 | environmentId: props.environmentId, |
| 433 | input: { |
| 434 | repository: props.repository, |
| 435 | number: props.number, |
| 436 | title, |
| 437 | body: rest.join("\n").trim(), |
| 438 | }, |
| 439 | }); |
| 440 | }); |
| 441 | return ( |
| 442 | <div className="border-b border-border/60 pb-1"> |
| 443 | <CommitMessageEditor |
| 444 | value={value} |
| 445 | readOnly={false} |
| 446 | placeholder="PR title" |
| 447 | onChange={(next) => { |
| 448 | setValue(next); |
| 449 | save(next); |
| 450 | }} |
| 451 | /> |
| 452 | </div> |
| 453 | ); |
| 454 | } |
| 455 | |
| 456 | function ReviewHeader(props: { |
| 457 | environmentId: EnvironmentId; |
| 458 | threadRef: ScopedThreadRef | null; |
| 459 | cwd: string; |
| 460 | repository: string | null; |
| 461 | changes: readonly JjChange[]; |
| 462 | pendingCount: number; |
| 463 | pullRequestNumber: number | null; |
| 464 | status: MergebotPullRequestLiveStatus | null; |
| 465 | pill: ReturnType<typeof mergebotStatusPill>; |
| 466 | diffLayout: string; |
| 467 | onToggleLayout: () => void; |
| 468 | }) { |
| 469 | const { environmentId, repository, changes, status } = props; |
| 470 | const push = useAtomCommand(jjEnvironment.push, { reportFailure: true }); |
| 471 | const openPullRequest = useAtomCommand(mergebotEnvironment.openPullRequest, { |
| 472 | reportFailure: true, |
| 473 | }); |
| 474 | const setAutoMerge = useAtomCommand(mergebotEnvironment.setAutoMerge, { reportFailure: true }); |
| 475 | const updatePullRequest = useAtomCommand(mergebotEnvironment.updatePullRequest, { |
| 476 | reportFailure: true, |
| 477 | }); |
| 478 | const linking = usePullRequestLinking(environmentId); |
| 479 | const openLink = useOpenChangeRequestLink(props.threadRef ?? undefined); |
| 480 | const [busy, setBusy] = useState<string | null>(null); |
| 481 | |
| 482 | const newest = changes[0]; |
| 483 | const undescribed = changes |
| 484 | .slice(0, props.pendingCount || changes.length) |
| 485 | .some((change) => !changeTitle(change)); |
| 486 | const blocked = undescribed ? "Describe or squash the working copy first" : null; |
| 487 | const shipping = status && (status.lifecycle !== "open" || status.queueStatus === "merging"); |
| 488 | |
| 489 | const run = async (label: string, work: () => Promise<void>) => { |
| 490 | setBusy(label); |
| 491 | try { |
| 492 | await work(); |
| 493 | } finally { |
| 494 | setBusy(null); |
| 495 | } |
| 496 | }; |
| 497 | |
| 498 | const open = (options: { autoMerge: boolean; draft: boolean }) => |
| 499 | run(options.autoMerge ? "auto-merge" : "open", async () => { |
| 500 | if (!newest || !repository) return; |
| 501 | const text = pullRequestText(changes); |
| 502 | const pushed = await push({ |
| 503 | environmentId, |
| 504 | input: { |
| 505 | cwd: props.cwd, |
| 506 | changeId: newest.changeId, |
| 507 | ...(text.baseChangeId ? { nameFrom: text.baseChangeId } : {}), |
| 508 | }, |
| 509 | }); |
| 510 | if (pushed._tag !== "Success") return; |
| 511 | const opened = await openPullRequest({ |
| 512 | environmentId, |
| 513 | input: { |
| 514 | repository, |
| 515 | head: pushed.value.bookmark, |
| 516 | title: text.title, |
| 517 | body: text.body, |
| 518 | ...options, |
| 519 | }, |
| 520 | }); |
| 521 | if (opened._tag !== "Success" || !props.threadRef) return; |
| 522 | await linking |
| 523 | .changeLink( |
| 524 | props.threadRef, |
| 525 | `https://github.com/${repository}/pull/${opened.value.number}`, |
| 526 | true, |
| 527 | ) |
| 528 | .catch(() => {}); |
| 529 | }); |
| 530 | |
| 531 | const pushFollowUps = () => |
| 532 | run("push", async () => { |
| 533 | if (!newest || !status) return; |
| 534 | await push({ |
| 535 | environmentId, |
| 536 | input: { cwd: props.cwd, changeId: newest.changeId, bookmark: status.headBranch }, |
| 537 | }); |
| 538 | }); |
| 539 | |
| 540 | const toggleAutoMerge = () => |
| 541 | run("auto-merge", async () => { |
| 542 | if (!status || !repository) return; |
| 543 | // The queue skips drafts, so asking a draft to auto-merge marks it ready first. |
| 544 | if (status.draft) { |
| 545 | const ready = await updatePullRequest({ |
| 546 | environmentId, |
| 547 | input: { repository, number: status.number, draft: false }, |
| 548 | }); |
| 549 | if (ready._tag !== "Success") return; |
| 550 | } |
| 551 | await setAutoMerge({ |
| 552 | environmentId, |
| 553 | input: { repository, number: status.number, enabled: !status.autoMerge }, |
| 554 | }); |
| 555 | }); |
| 556 | |
| 557 | return ( |
| 558 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 559 | {status && props.pill ? ( |
| 560 | <button |
| 561 | type="button" |
| 562 | className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-0.5 hover:bg-accent" |
| 563 | onClick={(event) => openLink(event, status.url)} |
| 564 | > |
| 565 | <MergebotStatusPill pill={props.pill} status={status} /> |
| 566 | <span className="font-mono text-xs text-muted-foreground">#{status.number}</span> |
| 567 | <ExternalLinkIcon className="size-3 text-muted-foreground" /> |
| 568 | </button> |
| 569 | ) : null} |
| 570 | <span className="flex-1" /> |
| 571 | {repository && props.pullRequestNumber === null && newest ? ( |
| 572 | <Group> |
| 573 | <Button |
| 574 | size="xs" |
| 575 | disabled={busy !== null || blocked !== null} |
| 576 | title={blocked ?? undefined} |
| 577 | onClick={() => void open({ autoMerge: true, draft: false })} |
| 578 | > |
| 579 | <PullRequestGlyph.merged /> |
| 580 | {busy === "auto-merge" ? "Opening…" : "Auto-merge"} |
| 581 | </Button> |
| 582 | <GroupSeparator /> |
| 583 | <Menu> |
| 584 | <MenuTrigger |
| 585 | render={ |
| 586 | <Button |
| 587 | size="icon-xs" |
| 588 | aria-label="More ways to open a PR" |
| 589 | disabled={busy !== null || blocked !== null} |
| 590 | /> |
| 591 | } |
| 592 | > |
| 593 | <ChevronDownIcon /> |
| 594 | </MenuTrigger> |
| 595 | <MenuPopup align="end"> |
| 596 | <MenuItem onClick={() => void open({ autoMerge: false, draft: false })}> |
| 597 | <MenuItemLabel>Open PR</MenuItemLabel> |
| 598 | </MenuItem> |
| 599 | <MenuItem onClick={() => void open({ autoMerge: false, draft: true })}> |
| 600 | <MenuItemLabel>Open as draft</MenuItemLabel> |
| 601 | </MenuItem> |
| 602 | </MenuPopup> |
| 603 | </Menu> |
| 604 | </Group> |
| 605 | ) : null} |
| 606 | {status && !shipping && props.pendingCount > 0 ? ( |
| 607 | <Button |
| 608 | size="xs" |
| 609 | disabled={busy !== null || blocked !== null} |
| 610 | title={blocked ?? undefined} |
| 611 | onClick={() => void pushFollowUps()} |
| 612 | > |
| 613 | <ArrowUpIcon /> |
| 614 | {busy === "push" ? "Pushing…" : `Push ${props.pendingCount}`} |
| 615 | </Button> |
| 616 | ) : null} |
| 617 | {status && !shipping ? ( |
| 618 | <Button |
| 619 | size="xs" |
| 620 | variant={props.pendingCount > 0 || status.autoMerge ? "outline" : "default"} |
| 621 | disabled={busy !== null} |
| 622 | onClick={() => void toggleAutoMerge()} |
| 623 | > |
| 624 | {status.autoMerge ? ( |
| 625 | "Cancel auto-merge" |
| 626 | ) : ( |
| 627 | <> |
| 628 | <PullRequestGlyph.merged /> |
| 629 | Auto-merge |
| 630 | </> |
| 631 | )} |
| 632 | </Button> |
| 633 | ) : null} |
| 634 | <Button |
| 635 | size="icon-xs" |
| 636 | variant="ghost" |
| 637 | aria-label={props.diffLayout === "split" ? "Unified diff" : "Split diff"} |
| 638 | onClick={props.onToggleLayout} |
| 639 | > |
| 640 | {props.diffLayout === "split" ? <Rows2Icon /> : <Columns2Icon />} |
| 641 | </Button> |
| 642 | </div> |
| 643 | ); |
| 644 | } |