authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-22 14:33:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 23:33:07+02:00
log78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3
tree9a61d0609c3f9ea659f930d5d1d89f9fbf6446f1
parent9a0a07167f0623c3a7db0ffeff2e3939760309df
signature Signed by PGP key B5690EEEBB952194

feat(web): copy branch name via right-click in the branch selector (#4275)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

1 files changed, 60 insertions(+), 9 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+60-9
...@@ -3,7 +3,7 @@ import {...@@ -3,7 +3,7 @@ import {
3 isAtomCommandInterrupted,3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";6import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
7import { LegendList, type LegendListRef } from "@legendapp/list/react";7import { LegendList, type LegendListRef } from "@legendapp/list/react";
8import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";8import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
9import {9import {
...@@ -17,9 +17,12 @@ import {...@@ -17,9 +17,12 @@ import {
17 useRef,17 useRef,
18 useState,18 useState,
19 useTransition,19 useTransition,
20 type MouseEvent as ReactMouseEvent,
20} from "react";21} from "react";
2122
22import { useComposerDraftStore, type DraftId } from "../composerDraftStore";23import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
24import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
25import { readLocalApi } from "../localApi";
23import { useOpenPrLink } from "../lib/openPullRequestLink";26import { useOpenPrLink } from "../lib/openPullRequestLink";
24import { usePaginatedBranches } from "../state/queries";27import { usePaginatedBranches } from "../state/queries";
25import { useProject, useThread } from "../state/entities";28import { useProject, useThread } from "../state/entities";
...@@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({...@@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({
317 // ---------------------------------------------------------------------------320 // ---------------------------------------------------------------------------
318 // Branch actions321 // Branch actions
319 // ---------------------------------------------------------------------------322 // ---------------------------------------------------------------------------
323 const copyBranchName = useCallback((branchName: string) => {
324 void writeTextToClipboard(branchName, "branch name").then(
325 (didCopy) => {
326 if (!didCopy) return;
327 toastManager.add({
328 type: "success",
329 title: "Branch name copied",
330 description: branchName,
331 });
332 },
333 (error: unknown) => {
334 toastManager.add(
335 stackedThreadToast({
336 type: "error",
337 title: "Failed to copy branch name",
338 description: toBranchActionErrorMessage(error),
339 }),
340 );
341 },
342 );
343 }, []);
344
345 const handleBranchContextMenu = useCallback(
346 (event: ReactMouseEvent, branchName: string | null) => {
347 if (!branchName) return;
348 const api = readLocalApi();
349 if (!api) return;
350 event.preventDefault();
351 event.stopPropagation();
352 const items: ContextMenuItem<"copy-branch-name">[] = [
353 { id: "copy-branch-name", label: "Copy branch name", icon: "copy" },
354 ];
355 void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => {
356 if (action === "copy-branch-name") copyBranchName(branchName);
357 });
358 },
359 [copyBranchName],
360 );
361
320 const runBranchAction = (action: () => Promise<void>) => {362 const runBranchAction = (action: () => Promise<void>) => {
321 startBranchActionTransition(async () => {363 startBranchActionTransition(async () => {
322 await action();364 await action();
...@@ -624,6 +666,7 @@ export function BranchToolbarBranchSelector({...@@ -624,6 +666,7 @@ export function BranchToolbarBranchSelector({
624 value={itemValue}666 value={itemValue}
625 className="pe-1.5"667 className="pe-1.5"
626 onClick={() => selectBranch(refName)}668 onClick={() => selectBranch(refName)}
669 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
627 >670 >
628 <div className="flex w-full min-w-0 items-center justify-between gap-2">671 <div className="flex w-full min-w-0 items-center justify-between gap-2">
629 <span className="min-w-0 flex-1 truncate">{itemValue}</span>672 <span className="min-w-0 flex-1 truncate">{itemValue}</span>
...@@ -674,15 +717,23 @@ export function BranchToolbarBranchSelector({...@@ -674,15 +717,23 @@ export function BranchToolbarBranchSelector({
674 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>717 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
675 </Tooltip>718 </Tooltip>
676 ) : null}719 ) : null}
677 <ComboboxTrigger720 {/* Context menu lives on the wrapper: the disabled Button has
678 render={<Button variant="ghost" size="xs" />}721 pointer-events-none, so the trigger itself never sees right-clicks
679 className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"722 while refs are loading or a branch action is pending. */}
680 disabled={isInitialBranchesLoadPending || isBranchActionPending}723 <span
724 className="flex min-w-0"
725 onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)}
681 >726 >
682 <GitBranchIcon className="size-3 shrink-0 opacity-70" />727 <ComboboxTrigger
683 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>728 render={<Button variant="ghost" size="xs" />}
684 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />729 className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"
685 </ComboboxTrigger>730 disabled={isInitialBranchesLoadPending || isBranchActionPending}
731 >
732 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
733 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
734 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
735 </ComboboxTrigger>
736 </span>
686 </div>737 </div>
687 <ComboboxPopup align="end" side="top" className="flex w-80 flex-col">738 <ComboboxPopup align="end" side="top" className="flex w-80 flex-col">
688 <div className="shrink-0 px-3 pt-2.5">739 <div className="shrink-0 px-3 pt-2.5">