Files
openchamber/packages/ui/src/components/views/git/BranchSelector.tsx
T
gsxdsm 6776ac31c2 feat(git): add multi-remote push with remote selection and fork-aware PR creation (#365)
* fix: Usage drop down should be scrollable

* fix: When there are multiple remotes, provide the user with an option of which branch to push to

* feat(gitview): add remote-push selection and auto checkout on create

* feat: enable selecting remote when creating PR

* fix: show PR icon in create button when not creating

* fix(pullrequest): drop remote picker and use explicit remote

* feat: add remote selection for PR status and creation

* fix: improve PR creation with selected remote and fork head

* chore(ui): simplify PR remote link UI

* fix(web): handle cross-repo PRs and branch validation

* feat: add remote selection for commit and push

* feat(git): delegate PR head source resolution to server

* chore(web): remove noisy PR creation logs
2026-02-09 19:28:41 +02:00

347 lines
12 KiB
TypeScript

import React from 'react';
import {
RiGitBranchLine,
RiArrowDownSLine,
RiAddLine,
RiCloseLine,
RiLoader4Line,
RiArrowLeftLine,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { GitRemote } from '@/lib/api/types';
interface BranchInfo {
ahead?: number;
behind?: number;
}
interface BranchSelectorProps {
currentBranch: string | null | undefined;
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
onCheckout: (branch: string) => void;
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
remotes?: GitRemote[];
disabled?: boolean;
tooltipDelayMs?: number;
}
const sanitizeBranchNameInput = (value: string): string => {
return value
.trim()
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9._/-]/g, '-')
.replace(/-+/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\/-+/g, '/')
.replace(/-+\//g, '/')
.replace(/^[-/]+/, '')
.replace(/[-/]+$/, '');
};
export const BranchSelector: React.FC<BranchSelectorProps> = ({
currentBranch,
localBranches,
remoteBranches,
branchInfo,
onCheckout,
onCreate,
remotes = [],
disabled = false,
tooltipDelayMs = 1000,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const createInputRef = React.useRef<HTMLInputElement>(null);
const hasMultipleRemotes = remotes.length > 1;
const sanitizedNewBranch = React.useMemo(
() => sanitizeBranchNameInput(newBranchName),
[newBranchName]
);
const filteredLocal = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return localBranches;
return localBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, localBranches]);
const filteredRemote = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, remoteBranches]);
const handleCheckout = (branch: string) => {
if (branch === currentBranch) {
setIsOpen(false);
return;
}
onCheckout(branch);
setIsOpen(false);
setSearch('');
};
const handleShowCreate = () => {
setShowCreate(true);
setTimeout(() => createInputRef.current?.focus(), 50);
};
const handleCreate = async () => {
if (!sanitizedNewBranch || isCreating) return;
// If multiple remotes, show remote selection first
if (hasMultipleRemotes) {
setShowRemoteSelect(true);
return;
}
// Single or no remote - proceed directly
setIsCreating(true);
try {
await onCreate(sanitizedNewBranch, remotes[0]);
setNewBranchName('');
setShowCreate(false);
setIsOpen(false);
} finally {
setIsCreating(false);
}
};
const handleSelectRemote = async (remote: GitRemote) => {
if (!sanitizedNewBranch || isCreating) return;
setIsCreating(true);
try {
await onCreate(sanitizedNewBranch, remote);
setNewBranchName('');
setShowCreate(false);
setShowRemoteSelect(false);
setIsOpen(false);
} finally {
setIsCreating(false);
}
};
const handleBackFromRemoteSelect = () => {
setShowRemoteSelect(false);
};
const handleCancelCreate = () => {
setNewBranchName('');
setShowCreate(false);
setShowRemoteSelect(false);
};
React.useEffect(() => {
if (!isOpen) {
setSearch('');
setShowCreate(false);
setShowRemoteSelect(false);
setNewBranchName('');
}
}, [isOpen]);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
>
<RiGitBranchLine className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || 'Detached HEAD'}
</span>
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Current branch
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
<Command className="h-full min-h-0">
<CommandInput
placeholder="Search branches..."
value={search}
onValueChange={setSearch}
/>
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandGroup>
{showRemoteSelect ? (
// Remote selection step
<div className="px-2 py-1.5">
<div className="flex items-center gap-2 mb-2">
<button
type="button"
onClick={handleBackFromRemoteSelect}
disabled={isCreating}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<RiArrowLeftLine className="size-4" />
</button>
<span className="typography-meta text-muted-foreground">
Push <span className="text-foreground font-medium">{sanitizedNewBranch}</span> to:
</span>
</div>
<div className="flex flex-col gap-1">
{remotes.map((remote) => (
<button
key={remote.name}
type="button"
onClick={() => handleSelectRemote(remote)}
disabled={isCreating}
className="flex flex-col items-start gap-0.5 px-2 py-1.5 rounded-md text-left hover:bg-accent disabled:opacity-50"
>
<span className="typography-ui-label text-foreground">
{isCreating ? (
<RiLoader4Line className="inline size-3 mr-1.5 animate-spin" />
) : null}
{remote.name}
</span>
<span className="typography-micro text-muted-foreground truncate max-w-full">
{remote.pushUrl}
</span>
</button>
))}
</div>
</div>
) : !showCreate ? (
<CommandItem onSelect={handleShowCreate}>
<RiAddLine className="size-4" />
<span>Create new branch...</span>
</CommandItem>
) : (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg">
<input
ref={createInputRef}
placeholder="New branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelCreate();
}
}}
className="flex-1 min-w-0 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={handleCreate}
disabled={!sanitizedNewBranch || isCreating}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{isCreating ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAddLine className="size-4" />
)}
</button>
<button
type="button"
onClick={handleCancelCreate}
disabled={isCreating}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<RiCloseLine className="size-4" />
</button>
</div>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Local branches">
{filteredLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
onSelect={() => handleCheckout(branch)}
>
<span className="flex flex-1 flex-col">
<span className="typography-ui-label text-foreground">
{branch}
</span>
{(branchInfo?.[branch]?.ahead || branchInfo?.[branch]?.behind) && (
<span className="typography-micro text-muted-foreground">
{branchInfo[branch].ahead || 0} ahead ·{' '}
{branchInfo[branch].behind || 0} behind
</span>
)}
</span>
{currentBranch === branch && (
<span className="typography-micro text-primary">Current</span>
)}
</CommandItem>
))}
{filteredLocal.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No local branches
</span>
</CommandItem>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Remote branches">
{filteredRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
onSelect={() => handleCheckout(branch)}
>
<span className="typography-ui-label text-foreground">{branch}</span>
</CommandItem>
))}
{filteredRemote.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No remote branches
</span>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
);
};