Files
openchamber/packages/ui/src/components/multirun/BranchSelector.tsx
T
Iuliia Ivashko 081be1b7d0 feat(worktrees): ship upstream-first worktree flow across web + vscode (#418)
* feat: add worktree validation and deleteLocalBranch option

Add API to validate and create worktrees with new payload types
Allow deleting local branches when removing worktrees via UI and API
Introduce OpenCode style random names for worktrees when not provided

* feat: enable SSH/HTTPS transport detection for PR picker

Load remotes for the current project directory to inform PR picker options.
Determine preferred push transport from remotes and apply it.
Expose sshUrl in API for frontend to build SSH clone URLs

* feat: extend head repo with sshUrl and improve push error messages

Add sshUrl field to head repo mapping
Enhance push failure handling to display stderr or stdout details
Return push details on success

* fix: worktree path

* feat: worktree set upstream on creation

Enable pushing to upstream by default when no remote is specified
Remove per-remote dropdown for push actions and auto-use first/upstream remote
Update server and VSCode git services to support push without explicit remote and set upstream

* fix: worktree-name sanitization

* feat: rename worktree path field and branch prefix

* feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution

* refactor(git): use git.worktree namespace in branch picker

* feat(worktrees): sync OpenCode sandbox metadata on create/remove

* fix(worktrees): accept new path key in workspace guard and validate remote startRef

* chore(docs): remove temporary worktree testing plan

* feat: add git worktree management API (list/create/delete/validate) for vscode

* feat: wire root tracking remote and defaults for new worktrees

Add resolveRootTrackingRemote to detect upstream remote for root branch
Apply upstream defaults when creating new worktrees to auto-set upstream
Replace validation and creation flow to use new worktreeCreate APIs

* feat(worktrees): enable root tracking remote handling
2026-02-13 19:18:22 +02:00

250 lines
7.8 KiB
TypeScript

import React from 'react';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
import { resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
export type WorktreeBaseOption = {
value: string;
label: string;
group: 'special' | 'local' | 'remote';
};
export interface BranchSelectorProps {
/** Current directory to check for git repository */
directory: string | null;
/** Currently selected branch */
value: string;
/** Called when branch selection changes */
onChange: (branch: string) => void;
/** Optional className for the trigger */
className?: string;
/** Whether the selector is disabled */
disabled?: boolean;
/** ID for accessibility */
id?: string;
}
export interface BranchSelectorState {
branches: WorktreeBaseOption[];
isLoading: boolean;
isGitRepository: boolean | null;
}
const parseTrackingRemote = (tracking: string | null | undefined): string | null => {
const value = String(tracking || '').trim().replace(/^remotes\//, '');
if (!value) {
return null;
}
const slashIndex = value.indexOf('/');
if (slashIndex <= 0) {
return null;
}
return value.slice(0, slashIndex);
};
/**
* Hook to load available git branches for a directory.
*/
// eslint-disable-next-line react-refresh/only-export-components -- Hook is tightly coupled with BranchSelector
export function useBranchOptions(directory: string | null): BranchSelectorState {
const [branches, setBranches] = React.useState<WorktreeBaseOption[]>([
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
]);
const [isLoading, setIsLoading] = React.useState(false);
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
React.useEffect(() => {
let cancelled = false;
if (!directory) {
setIsGitRepository(null);
setIsLoading(false);
setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
return;
}
setIsLoading(true);
setIsGitRepository(null);
(async () => {
try {
const isGit = await checkIsGitRepository(directory);
if (cancelled) return;
setIsGitRepository(isGit);
if (!isGit) {
setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
return;
}
const branchData = await getGitBranches(directory).catch(() => null);
if (cancelled) return;
const rootTrackingRemote = await resolveRootTrackingRemote(directory).catch(() => null);
if (cancelled) return;
const worktreeBaseOptions: WorktreeBaseOption[] = [];
const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)';
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
if (branchData) {
const localBranches = branchData.all
.filter((branchName) => !branchName.startsWith('remotes/'))
.filter((branchName) => {
if (!rootTrackingRemote) {
return true;
}
const tracking = branchData.branches?.[branchName]?.tracking;
const trackingRemote = parseTrackingRemote(tracking);
if (!trackingRemote) {
return true;
}
return trackingRemote === rootTrackingRemote;
})
.sort((a, b) => a.localeCompare(b));
localBranches.forEach((branchName) => {
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' });
});
const remoteBranches = branchData.all
.filter((branchName) => branchName.startsWith('remotes/'))
.map((branchName) => branchName.replace(/^remotes\//, ''))
.filter((branchName) => {
if (!rootTrackingRemote) {
return true;
}
const slashIndex = branchName.indexOf('/');
if (slashIndex <= 0) {
return false;
}
return branchName.slice(0, slashIndex) === rootTrackingRemote;
})
.sort((a, b) => a.localeCompare(b));
remoteBranches.forEach((branchName) => {
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' });
});
}
setBranches(worktreeBaseOptions);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [directory]);
return { branches, isLoading, isGitRepository };
}
/**
* Branch selector dropdown for selecting a base branch for worktree creation.
*/
export const BranchSelector: React.FC<BranchSelectorProps> = ({
directory,
value,
onChange,
className,
disabled,
id,
}) => {
const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
const selectedLabel = React.useMemo(() => {
return branches.find((option) => option.value === value)?.label ?? null;
}, [branches, value]);
// Update value if it's no longer valid
React.useEffect(() => {
const isValid = branches.some((option) => option.value === value);
if (!isValid && branches.length > 0) {
onChange('HEAD');
}
}, [branches, value, onChange]);
const isDisabled = disabled || !isGitRepository || isLoading;
return (
<div className="space-y-2">
<Select
value={value}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger
id={id}
size="lg"
className={className ?? 'max-w-full typography-meta text-foreground'}
>
{selectedLabel ? (
<SelectValue>{selectedLabel}</SelectValue>
) : (
<SelectValue placeholder={isLoading ? 'Loading branches…' : 'Select a branch'} />
)}
</SelectTrigger>
<SelectContent fitContent>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{branches
.filter((option) => option.group === 'special')
.map((option) => (
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
{option.label}
</SelectItem>
))}
</SelectGroup>
{branches.some((option) => option.group === 'local') ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{branches
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
) : null}
{branches.some((option) => option.group === 'remote') ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Remote branches</SelectLabel>
{branches
.filter((option) => option.group === 'remote')
.map((option) => (
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
) : null}
</SelectContent>
</Select>
{isGitRepository === false && (
<p className="typography-micro text-muted-foreground/70">Not in a git repository.</p>
)}
</div>
);
};