feat(worktree): add start point option for new branch creation
Allow selecting a base branch when creating new worktree branches Add dropdown to choose start point from existing branches Extend worktree creation flow to support custom branch origins
This commit is contained in:
@@ -1366,6 +1366,7 @@ pub async fn add_git_worktree(
|
|||||||
path_str: String,
|
path_str: String,
|
||||||
branch: String,
|
branch: String,
|
||||||
create_branch: Option<bool>,
|
create_branch: Option<bool>,
|
||||||
|
start_point: Option<String>,
|
||||||
state: State<'_, DesktopRuntime>,
|
state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let root = validate_git_path(&directory, state.settings())
|
let root = validate_git_path(&directory, state.settings())
|
||||||
@@ -1381,6 +1382,11 @@ pub async fn add_git_worktree(
|
|||||||
|
|
||||||
if !create_branch.unwrap_or(false) {
|
if !create_branch.unwrap_or(false) {
|
||||||
args.push(&branch);
|
args.push(&branch);
|
||||||
|
} else if let Some(start_point) = start_point.as_deref() {
|
||||||
|
let start_point = start_point.trim();
|
||||||
|
if !start_point.is_empty() {
|
||||||
|
args.push(start_point);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run_git(&args, &root).await.map_err(|e| e.to_string())?;
|
run_git(&args, &root).await.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
|||||||
directory,
|
directory,
|
||||||
pathStr: payload.path,
|
pathStr: payload.path,
|
||||||
branch: payload.branch,
|
branch: payload.branch,
|
||||||
createBranch: payload.createBranch
|
createBranch: payload.createBranch,
|
||||||
|
startPoint: payload.startPoint,
|
||||||
});
|
});
|
||||||
return { success: true, path: payload.path, branch: payload.branch };
|
return { success: true, path: payload.path, branch: payload.branch };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,7 +34,7 @@ import {
|
|||||||
mapWorktreeToMetadata,
|
mapWorktreeToMetadata,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
} from '@/lib/git/worktreeService';
|
} from '@/lib/git/worktreeService';
|
||||||
import { checkIsGitRepository, ensureOpenChamberIgnored } from '@/lib/gitApi';
|
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
@@ -78,6 +89,14 @@ const joinWorktreePath = (projectDirectory: string, slug: string): string => {
|
|||||||
return cleanSlug ? `${base}/${cleanSlug}` : base;
|
return cleanSlug ? `${base}/${cleanSlug}` : base;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorktreeBaseOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
group: 'special' | 'local' | 'remote';
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorktreeCreateMode = 'new' | 'existing';
|
||||||
|
|
||||||
type DeleteDialogState = {
|
type DeleteDialogState = {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
dateLabel?: string;
|
dateLabel?: string;
|
||||||
@@ -88,7 +107,14 @@ type DeleteDialogState = {
|
|||||||
export const SessionDialogs: React.FC = () => {
|
export const SessionDialogs: React.FC = () => {
|
||||||
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
||||||
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
||||||
|
const [worktreeCreateMode, setWorktreeCreateMode] = React.useState<WorktreeCreateMode>('new');
|
||||||
const [branchName, setBranchName] = React.useState<string>('');
|
const [branchName, setBranchName] = React.useState<string>('');
|
||||||
|
const [existingWorktreeBranch, setExistingWorktreeBranch] = React.useState<string>('');
|
||||||
|
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||||
|
const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState<WorktreeBaseOption[]>([
|
||||||
|
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
|
||||||
|
]);
|
||||||
|
const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false);
|
||||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||||
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
|
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
|
||||||
@@ -120,8 +146,12 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
||||||
|
|
||||||
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
|
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
|
||||||
const sanitizedBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
||||||
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(sanitizedBranchName), [sanitizedBranchName]);
|
const worktreeTargetBranch = React.useMemo(
|
||||||
|
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
|
||||||
|
[existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode],
|
||||||
|
);
|
||||||
|
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]);
|
||||||
const worktreePreviewPath = React.useMemo(() => {
|
const worktreePreviewPath = React.useMemo(() => {
|
||||||
if (!projectDirectory || !sanitizedWorktreeSlug) {
|
if (!projectDirectory || !sanitizedWorktreeSlug) {
|
||||||
return '';
|
return '';
|
||||||
@@ -129,6 +159,16 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
|
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
|
||||||
}, [projectDirectory, sanitizedWorktreeSlug]);
|
}, [projectDirectory, sanitizedWorktreeSlug]);
|
||||||
const isGitRepo = isGitRepository === true;
|
const isGitRepo = isGitRepository === true;
|
||||||
|
const selectedWorktreeBaseLabel = React.useMemo(() => {
|
||||||
|
const match = availableWorktreeBaseBranches.find((option) => option.value === worktreeBaseBranch);
|
||||||
|
if (match) {
|
||||||
|
return match.label;
|
||||||
|
}
|
||||||
|
if (worktreeBaseBranch === 'HEAD') {
|
||||||
|
return 'Current (HEAD)';
|
||||||
|
}
|
||||||
|
return worktreeBaseBranch;
|
||||||
|
}, [availableWorktreeBaseBranches, worktreeBaseBranch]);
|
||||||
const hasDirtyWorktrees = React.useMemo(
|
const hasDirtyWorktrees = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
|
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
|
||||||
@@ -181,7 +221,12 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isSessionCreateDialogOpen) {
|
if (!isSessionCreateDialogOpen) {
|
||||||
|
setWorktreeCreateMode('new');
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
|
setExistingWorktreeBranch('');
|
||||||
|
setWorktreeBaseBranch('HEAD');
|
||||||
|
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
||||||
|
setIsLoadingWorktreeBaseBranches(false);
|
||||||
setAvailableWorktrees([]);
|
setAvailableWorktrees([]);
|
||||||
setWorktreeError(null);
|
setWorktreeError(null);
|
||||||
setIsLoadingWorktrees(false);
|
setIsLoadingWorktrees(false);
|
||||||
@@ -192,6 +237,10 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!projectDirectory) {
|
if (!projectDirectory) {
|
||||||
|
setWorktreeCreateMode('new');
|
||||||
|
setExistingWorktreeBranch('');
|
||||||
|
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
||||||
|
setWorktreeBaseBranch('HEAD');
|
||||||
setAvailableWorktrees([]);
|
setAvailableWorktrees([]);
|
||||||
setIsGitRepository(null);
|
setIsGitRepository(null);
|
||||||
setIsCheckingGitRepository(false);
|
setIsCheckingGitRepository(false);
|
||||||
@@ -200,6 +249,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoadingWorktrees(true);
|
setIsLoadingWorktrees(true);
|
||||||
|
setIsLoadingWorktreeBaseBranches(true);
|
||||||
setIsCheckingGitRepository(true);
|
setIsCheckingGitRepository(true);
|
||||||
setWorktreeError(null);
|
setWorktreeError(null);
|
||||||
|
|
||||||
@@ -212,13 +262,60 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
setIsGitRepository(repoStatus);
|
setIsGitRepository(repoStatus);
|
||||||
|
|
||||||
if (!repoStatus) {
|
if (!repoStatus) {
|
||||||
|
setWorktreeCreateMode('new');
|
||||||
|
setExistingWorktreeBranch('');
|
||||||
|
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
||||||
|
setWorktreeBaseBranch('HEAD');
|
||||||
setAvailableWorktrees([]);
|
setAvailableWorktrees([]);
|
||||||
setWorktreeError(null);
|
setWorktreeError(null);
|
||||||
} else {
|
} else {
|
||||||
const worktrees = await listGitWorktrees(projectDirectory);
|
const [worktrees, branches] = await Promise.all([
|
||||||
|
listGitWorktrees(projectDirectory),
|
||||||
|
getGitBranches(projectDirectory).catch(() => null),
|
||||||
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeBaseOptions: WorktreeBaseOption[] = [];
|
||||||
|
const headLabel = branches?.current
|
||||||
|
? `Current (HEAD: ${branches.current})`
|
||||||
|
: 'Current (HEAD)';
|
||||||
|
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||||
|
|
||||||
|
if (branches) {
|
||||||
|
const localBranches = branches.all
|
||||||
|
.filter((name) => !name.startsWith('remotes/'))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
const defaultExistingBranch = branches.current && !branches.current.startsWith('remotes/')
|
||||||
|
? branches.current
|
||||||
|
: (localBranches[0] ?? '');
|
||||||
|
setExistingWorktreeBranch((previous) => {
|
||||||
|
const trimmed = previous.trim();
|
||||||
|
if (trimmed && localBranches.includes(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return defaultExistingBranch;
|
||||||
|
});
|
||||||
|
|
||||||
|
localBranches.forEach((name) => {
|
||||||
|
worktreeBaseOptions.push({ value: name, label: name, group: 'local' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteBranches = branches.all
|
||||||
|
.filter((name) => name.startsWith('remotes/'))
|
||||||
|
.map((name) => name.replace(/^remotes\//, ''))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
remoteBranches.forEach((name) => {
|
||||||
|
worktreeBaseOptions.push({ value: name, label: name, group: 'remote' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableWorktreeBaseBranches(worktreeBaseOptions);
|
||||||
|
setWorktreeBaseBranch((previous) =>
|
||||||
|
worktreeBaseOptions.some((option) => option.value === previous) ? previous : 'HEAD'
|
||||||
|
);
|
||||||
|
|
||||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info));
|
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info));
|
||||||
const worktreeRoot = joinWorktreePath(projectDirectory, '');
|
const worktreeRoot = joinWorktreePath(projectDirectory, '');
|
||||||
const worktreePrefix = `${worktreeRoot}/`;
|
const worktreePrefix = `${worktreeRoot}/`;
|
||||||
@@ -234,6 +331,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoadingWorktrees(false);
|
setIsLoadingWorktrees(false);
|
||||||
|
setIsLoadingWorktreeBaseBranches(false);
|
||||||
setIsCheckingGitRepository(false);
|
setIsCheckingGitRepository(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +372,11 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return sessionEvents.onCreateRequest(() => {
|
return sessionEvents.onCreateRequest(() => {
|
||||||
|
setWorktreeCreateMode('new');
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
|
setExistingWorktreeBranch('');
|
||||||
|
setWorktreeBaseBranch('HEAD');
|
||||||
|
setWorktreeError(null);
|
||||||
setSessionCreateDialogOpen(true);
|
setSessionCreateDialogOpen(true);
|
||||||
});
|
});
|
||||||
}, [setSessionCreateDialogOpen]);
|
}, [setSessionCreateDialogOpen]);
|
||||||
@@ -378,10 +480,13 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedBranch = sanitizedBranchName;
|
const normalizedBranch = worktreeTargetBranch;
|
||||||
const slugValue = sanitizedWorktreeSlug;
|
const slugValue = sanitizedWorktreeSlug;
|
||||||
if (!normalizedBranch) {
|
if (!normalizedBranch) {
|
||||||
const message = 'Provide a branch name for the new worktree.';
|
const message =
|
||||||
|
worktreeCreateMode === 'existing'
|
||||||
|
? 'Select an existing branch for the new worktree.'
|
||||||
|
: 'Provide a branch name for the new worktree.';
|
||||||
setWorktreeError(message);
|
setWorktreeError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return false;
|
return false;
|
||||||
@@ -402,7 +507,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
setWorktreeError(null);
|
setWorktreeError(null);
|
||||||
return true;
|
return true;
|
||||||
}, [projectDirectory, sanitizedBranchName, sanitizedWorktreeSlug, availableWorktrees]);
|
}, [projectDirectory, worktreeCreateMode, worktreeTargetBranch, sanitizedWorktreeSlug, availableWorktrees]);
|
||||||
|
|
||||||
const handleCreateWorktree = async () => {
|
const handleCreateWorktree = async () => {
|
||||||
if (isCreatingWorktree || isLoading) {
|
if (isCreatingWorktree || isLoading) {
|
||||||
@@ -419,13 +524,18 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
let cleanupMetadata: WorktreeMetadata | null = null;
|
let cleanupMetadata: WorktreeMetadata | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const normalizedBranch = sanitizedBranchName;
|
const normalizedBranch = worktreeTargetBranch;
|
||||||
const slugValue = sanitizedWorktreeSlug;
|
const slugValue = sanitizedWorktreeSlug;
|
||||||
|
const shouldCreateBranch = worktreeCreateMode === 'new';
|
||||||
|
const startPoint = shouldCreateBranch && worktreeBaseBranch && worktreeBaseBranch !== 'HEAD'
|
||||||
|
? worktreeBaseBranch
|
||||||
|
: undefined;
|
||||||
const metadata = await createWorktree({
|
const metadata = await createWorktree({
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
worktreeSlug: slugValue,
|
worktreeSlug: slugValue,
|
||||||
branch: normalizedBranch,
|
branch: normalizedBranch,
|
||||||
createBranch: true,
|
createBranch: shouldCreateBranch,
|
||||||
|
startPoint,
|
||||||
});
|
});
|
||||||
cleanupMetadata = metadata;
|
cleanupMetadata = metadata;
|
||||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||||
@@ -446,6 +556,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
await refreshWorktrees();
|
await refreshWorktrees();
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
|
setExistingWorktreeBranch('');
|
||||||
toast.success('Worktree created');
|
toast.success('Worktree created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cleanupMetadata) {
|
if (cleanupMetadata) {
|
||||||
@@ -556,41 +667,195 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
|
<AnimatedTabs
|
||||||
Branch name
|
tabs={[
|
||||||
</label>
|
{ value: 'new', label: 'New branch' },
|
||||||
<div className="flex gap-2">
|
{ value: 'existing', label: 'Existing branch' },
|
||||||
<Input
|
]}
|
||||||
id="worktree-branch-input"
|
value={worktreeCreateMode}
|
||||||
value={branchName}
|
onValueChange={(value) => {
|
||||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
setWorktreeCreateMode(value);
|
||||||
placeholder="feature/new-branch"
|
setWorktreeError(null);
|
||||||
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
|
||||||
disabled={!isGitRepo || isCheckingGitRepository}
|
if (value === 'existing' && !existingWorktreeBranch) {
|
||||||
onKeyDown={(e) => {
|
const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? '';
|
||||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
if (firstLocal) {
|
||||||
handleCreateWorktree();
|
setExistingWorktreeBranch(firstLocal);
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
<Button
|
animate={false}
|
||||||
onClick={handleCreateWorktree}
|
/>
|
||||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !sanitizedBranchName}
|
|
||||||
className="h-8"
|
{worktreeCreateMode === 'new' ? (
|
||||||
>
|
<>
|
||||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-base-branch-select">
|
||||||
</Button>
|
Base branch
|
||||||
</div>
|
</label>
|
||||||
{sanitizedBranchName && (
|
<Select
|
||||||
<p className="typography-micro text-muted-foreground/70">
|
value={worktreeBaseBranch}
|
||||||
Creates branch{' '}
|
onValueChange={setWorktreeBaseBranch}
|
||||||
<code className="font-mono text-xs text-muted-foreground">{sanitizedBranchName}</code>
|
disabled={!isGitRepo || isCheckingGitRepository || isLoadingWorktreeBaseBranches}
|
||||||
{' '}at{' '}
|
>
|
||||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
<SelectTrigger
|
||||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
id="worktree-base-branch-select"
|
||||||
</code>
|
size="lg"
|
||||||
</p>
|
className="w-full typography-meta text-foreground"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Default</SelectLabel>
|
||||||
|
{availableWorktreeBaseBranches
|
||||||
|
.filter((option) => option.group === 'special')
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
|
||||||
|
{availableWorktreeBaseBranches.some((option) => option.group === 'local') && (
|
||||||
|
<>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Local branches</SelectLabel>
|
||||||
|
{availableWorktreeBaseBranches
|
||||||
|
.filter((option) => option.group === 'local')
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availableWorktreeBaseBranches.some((option) => option.group === 'remote') && (
|
||||||
|
<>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Remote branches</SelectLabel>
|
||||||
|
{availableWorktreeBaseBranches
|
||||||
|
.filter((option) => option.group === 'remote')
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
|
||||||
|
New branch name
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="worktree-branch-input"
|
||||||
|
value={branchName}
|
||||||
|
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||||
|
placeholder="feature/new-branch"
|
||||||
|
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||||
|
disabled={!isGitRepo || isCheckingGitRepository}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||||
|
handleCreateWorktree();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateWorktree}
|
||||||
|
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-existing-branch-select">
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={existingWorktreeBranch}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setExistingWorktreeBranch(value);
|
||||||
|
setWorktreeError(null);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!isGitRepo
|
||||||
|
|| isCheckingGitRepository
|
||||||
|
|| isLoadingWorktreeBaseBranches
|
||||||
|
|| !availableWorktreeBaseBranches.some((option) => option.group === 'local')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="worktree-existing-branch-select"
|
||||||
|
size="lg"
|
||||||
|
className="flex-1 typography-meta text-foreground"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Local branches</SelectLabel>
|
||||||
|
{availableWorktreeBaseBranches
|
||||||
|
.filter((option) => option.group === 'local')
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateWorktree}
|
||||||
|
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? (
|
||||||
|
<p className="typography-micro text-muted-foreground/70">
|
||||||
|
No local branches found. Fetch or create a branch first.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{worktreeTargetBranch ? (
|
||||||
|
<p className="typography-micro text-muted-foreground/70">
|
||||||
|
{worktreeCreateMode === 'existing' ? (
|
||||||
|
<>
|
||||||
|
Uses branch{' '}
|
||||||
|
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||||
|
{' '}at{' '}
|
||||||
|
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||||
|
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Creates branch{' '}
|
||||||
|
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||||
|
{' '}from{' '}
|
||||||
|
<code className="font-mono text-xs text-muted-foreground">{selectedWorktreeBaseLabel}</code>
|
||||||
|
{' '}at{' '}
|
||||||
|
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||||
|
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
|
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default" | "lg"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export interface GitAddWorktreePayload {
|
|||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
createBranch?: boolean;
|
createBranch?: boolean;
|
||||||
|
startPoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitRemoveWorktreePayload {
|
export interface GitRemoveWorktreePayload {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface CreateWorktreeOptions {
|
|||||||
worktreeSlug: string;
|
worktreeSlug: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
createBranch?: boolean;
|
createBranch?: boolean;
|
||||||
|
startPoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveWorktreeOptions {
|
export interface RemoveWorktreeOptions {
|
||||||
@@ -84,7 +85,7 @@ export async function resolveWorktreePath(projectDirectory: string, worktreeSlug
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
|
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
|
||||||
const { projectDirectory, worktreeSlug, branch, createBranch } = options;
|
const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options;
|
||||||
const normalizedProject = normalize(projectDirectory);
|
const normalizedProject = normalize(projectDirectory);
|
||||||
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
|
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ export async function createWorktree(options: CreateWorktreeOptions): Promise<Wo
|
|||||||
path: worktreePath,
|
path: worktreePath,
|
||||||
branch,
|
branch,
|
||||||
createBranch: Boolean(createBranch),
|
createBranch: Boolean(createBranch),
|
||||||
|
startPoint: startPoint?.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await addGitWorktree(normalizedProject, payload);
|
await addGitWorktree(normalizedProject, payload);
|
||||||
|
|||||||
@@ -3400,12 +3400,12 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'directory parameter is required' });
|
return res.status(400).json({ error: 'directory parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path, branch, createBranch } = req.body;
|
const { path, branch, createBranch, startPoint } = req.body;
|
||||||
if (!path || !branch) {
|
if (!path || !branch) {
|
||||||
return res.status(400).json({ error: 'path and branch are required' });
|
return res.status(400).json({ error: 'path and branch are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await addWorktree(directory, path, branch, { createBranch });
|
const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add worktree:', error);
|
console.error('Failed to add worktree:', error);
|
||||||
|
|||||||
@@ -830,6 +830,7 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const args = ['worktree', 'add'];
|
const args = ['worktree', 'add'];
|
||||||
|
const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : '';
|
||||||
|
|
||||||
if (options.createBranch) {
|
if (options.createBranch) {
|
||||||
args.push('-b', branch);
|
args.push('-b', branch);
|
||||||
@@ -839,6 +840,8 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
|
|||||||
|
|
||||||
if (!options.createBranch) {
|
if (!options.createBranch) {
|
||||||
args.push(branch);
|
args.push(branch);
|
||||||
|
} else if (startPoint) {
|
||||||
|
args.push(startPoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.raw(args);
|
await git.raw(args);
|
||||||
|
|||||||
Reference in New Issue
Block a user