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 {
33 isAtomCommandInterrupted,
44 squashAtomCommandFailure,
55} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
6import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
77import { LegendList, type LegendListRef } from "@legendapp/list/react";
88import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
99import {
......@@ -17,9 +17,12 @@ import {
1717 useRef,
1818 useState,
1919 useTransition,
20 type MouseEvent as ReactMouseEvent,
2021} from "react";
2122
2223import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
24import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
25import { readLocalApi } from "../localApi";
2326import { useOpenPrLink } from "../lib/openPullRequestLink";
2427import { usePaginatedBranches } from "../state/queries";
2528import { useProject, useThread } from "../state/entities";
......@@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({
317320 // ---------------------------------------------------------------------------
318321 // Branch actions
319322 // ---------------------------------------------------------------------------
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
320362 const runBranchAction = (action: () => Promise<void>) => {
321363 startBranchActionTransition(async () => {
322364 await action();
......@@ -624,6 +666,7 @@ export function BranchToolbarBranchSelector({
624666 value={itemValue}
625667 className="pe-1.5"
626668 onClick={() => selectBranch(refName)}
669 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
627670 >
628671 <div className="flex w-full min-w-0 items-center justify-between gap-2">
629672 <span className="min-w-0 flex-1 truncate">{itemValue}</span>
......@@ -674,15 +717,23 @@ export function BranchToolbarBranchSelector({
674717 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
675718 </Tooltip>
676719 ) : null}
677 <ComboboxTrigger
678 render={<Button variant="ghost" size="xs" />}
679 className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"
680 disabled={isInitialBranchesLoadPending || isBranchActionPending}
720 {/* Context menu lives on the wrapper: the disabled Button has
721 pointer-events-none, so the trigger itself never sees right-clicks
722 while refs are loading or a branch action is pending. */}
723 <span
724 className="flex min-w-0"
725 onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)}
681726 >
682 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
683 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
684 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
685 </ComboboxTrigger>
727 <ComboboxTrigger
728 render={<Button variant="ghost" size="xs" />}
729 className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"
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>
686737 </div>
687738 <ComboboxPopup align="end" side="top" className="flex w-80 flex-col">
688739 <div className="shrink-0 px-3 pt-2.5">