diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 71e6bd0c..c816de7d 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -1366,6 +1366,7 @@ pub async fn add_git_worktree( path_str: String, branch: String, create_branch: Option, + start_point: Option, state: State<'_, DesktopRuntime>, ) -> Result<(), String> { let root = validate_git_path(&directory, state.settings()) @@ -1381,6 +1382,11 @@ pub async fn add_git_worktree( if !create_branch.unwrap_or(false) { 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())?; diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index 2edb2c68..faf4753f 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -116,7 +116,8 @@ export const createDesktopGitAPI = (): GitAPI => ({ directory, pathStr: payload.path, branch: payload.branch, - createBranch: payload.createBranch + createBranch: payload.createBranch, + startPoint: payload.startPoint, }); return { success: true, path: payload.path, branch: payload.branch }; }, diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 81dea29f..b3163ea7 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -1,7 +1,18 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { toast } from 'sonner'; +import { AnimatedTabs } from '@/components/ui/animated-tabs'; import { Dialog, DialogContent, @@ -23,7 +34,7 @@ import { mapWorktreeToMetadata, removeWorktree, } from '@/lib/git/worktreeService'; -import { checkIsGitRepository, ensureOpenChamberIgnored } from '@/lib/gitApi'; +import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -78,6 +89,14 @@ const joinWorktreePath = (projectDirectory: string, slug: string): string => { return cleanSlug ? `${base}/${cleanSlug}` : base; }; +type WorktreeBaseOption = { + value: string; + label: string; + group: 'special' | 'local' | 'remote'; +}; + +type WorktreeCreateMode = 'new' | 'existing'; + type DeleteDialogState = { sessions: Session[]; dateLabel?: string; @@ -88,7 +107,14 @@ type DeleteDialogState = { export const SessionDialogs: React.FC = () => { const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); + const [worktreeCreateMode, setWorktreeCreateMode] = React.useState('new'); const [branchName, setBranchName] = React.useState(''); + const [existingWorktreeBranch, setExistingWorktreeBranch] = React.useState(''); + const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); + const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState([ + { value: 'HEAD', label: 'Current (HEAD)', group: 'special' }, + ]); + const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false); const [availableWorktrees, setAvailableWorktrees] = React.useState([]); const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); const [worktreeError, setWorktreeError] = React.useState(null); @@ -120,8 +146,12 @@ export const SessionDialogs: React.FC = () => { const useMobileOverlay = isMobile || isTablet || hasTouchInput; const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]); - const sanitizedBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]); - const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(sanitizedBranchName), [sanitizedBranchName]); + const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]); + const worktreeTargetBranch = React.useMemo( + () => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName), + [existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode], + ); + const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]); const worktreePreviewPath = React.useMemo(() => { if (!projectDirectory || !sanitizedWorktreeSlug) { return ''; @@ -129,6 +159,16 @@ export const SessionDialogs: React.FC = () => { return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug); }, [projectDirectory, sanitizedWorktreeSlug]); 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( () => (deleteDialog?.worktree?.status?.isDirty ?? false) || @@ -181,7 +221,12 @@ export const SessionDialogs: React.FC = () => { React.useEffect(() => { if (!isSessionCreateDialogOpen) { + setWorktreeCreateMode('new'); setBranchName(''); + setExistingWorktreeBranch(''); + setWorktreeBaseBranch('HEAD'); + setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); + setIsLoadingWorktreeBaseBranches(false); setAvailableWorktrees([]); setWorktreeError(null); setIsLoadingWorktrees(false); @@ -192,6 +237,10 @@ export const SessionDialogs: React.FC = () => { } if (!projectDirectory) { + setWorktreeCreateMode('new'); + setExistingWorktreeBranch(''); + setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); + setWorktreeBaseBranch('HEAD'); setAvailableWorktrees([]); setIsGitRepository(null); setIsCheckingGitRepository(false); @@ -200,6 +249,7 @@ export const SessionDialogs: React.FC = () => { let cancelled = false; setIsLoadingWorktrees(true); + setIsLoadingWorktreeBaseBranches(true); setIsCheckingGitRepository(true); setWorktreeError(null); @@ -212,13 +262,60 @@ export const SessionDialogs: React.FC = () => { setIsGitRepository(repoStatus); if (!repoStatus) { + setWorktreeCreateMode('new'); + setExistingWorktreeBranch(''); + setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); + setWorktreeBaseBranch('HEAD'); setAvailableWorktrees([]); setWorktreeError(null); } else { - const worktrees = await listGitWorktrees(projectDirectory); + const [worktrees, branches] = await Promise.all([ + listGitWorktrees(projectDirectory), + getGitBranches(projectDirectory).catch(() => null), + ]); if (cancelled) { 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 worktreeRoot = joinWorktreePath(projectDirectory, ''); const worktreePrefix = `${worktreeRoot}/`; @@ -234,6 +331,7 @@ export const SessionDialogs: React.FC = () => { } finally { if (!cancelled) { setIsLoadingWorktrees(false); + setIsLoadingWorktreeBaseBranches(false); setIsCheckingGitRepository(false); } } @@ -274,7 +372,11 @@ export const SessionDialogs: React.FC = () => { React.useEffect(() => { return sessionEvents.onCreateRequest(() => { + setWorktreeCreateMode('new'); setBranchName(''); + setExistingWorktreeBranch(''); + setWorktreeBaseBranch('HEAD'); + setWorktreeError(null); setSessionCreateDialogOpen(true); }); }, [setSessionCreateDialogOpen]); @@ -378,10 +480,13 @@ export const SessionDialogs: React.FC = () => { return false; } - const normalizedBranch = sanitizedBranchName; + const normalizedBranch = worktreeTargetBranch; const slugValue = sanitizedWorktreeSlug; 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); toast.error(message); return false; @@ -402,7 +507,7 @@ export const SessionDialogs: React.FC = () => { setWorktreeError(null); return true; - }, [projectDirectory, sanitizedBranchName, sanitizedWorktreeSlug, availableWorktrees]); + }, [projectDirectory, worktreeCreateMode, worktreeTargetBranch, sanitizedWorktreeSlug, availableWorktrees]); const handleCreateWorktree = async () => { if (isCreatingWorktree || isLoading) { @@ -419,13 +524,18 @@ export const SessionDialogs: React.FC = () => { let cleanupMetadata: WorktreeMetadata | null = null; try { - const normalizedBranch = sanitizedBranchName; + const normalizedBranch = worktreeTargetBranch; const slugValue = sanitizedWorktreeSlug; + const shouldCreateBranch = worktreeCreateMode === 'new'; + const startPoint = shouldCreateBranch && worktreeBaseBranch && worktreeBaseBranch !== 'HEAD' + ? worktreeBaseBranch + : undefined; const metadata = await createWorktree({ projectDirectory, worktreeSlug: slugValue, branch: normalizedBranch, - createBranch: true, + createBranch: shouldCreateBranch, + startPoint, }); cleanupMetadata = metadata; const status = await getWorktreeStatus(metadata.path).catch(() => undefined); @@ -446,6 +556,7 @@ export const SessionDialogs: React.FC = () => { await refreshWorktrees(); setBranchName(''); + setExistingWorktreeBranch(''); toast.success('Worktree created'); } catch (error) { if (cleanupMetadata) { @@ -556,41 +667,195 @@ export const SessionDialogs: React.FC = () => {
- -
- 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(); + { + setWorktreeCreateMode(value); + setWorktreeError(null); + + if (value === 'existing' && !existingWorktreeBranch) { + const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? ''; + if (firstLocal) { + setExistingWorktreeBranch(firstLocal); } - }} - /> - -
- {sanitizedBranchName && ( -

- Creates branch{' '} - {sanitizedBranchName} - {' '}at{' '} - - {formatPathForDisplay(worktreePreviewPath, homeDirectory)} - -

+ } + }} + animate={false} + /> + + {worktreeCreateMode === 'new' ? ( + <> + + + + +
+ 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(); + } + }} + /> + +
+ + ) : ( + <> + +
+ + +
+ {!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? ( +

+ No local branches found. Fetch or create a branch first. +

+ ) : null} + )} + + {worktreeTargetBranch ? ( +

+ {worktreeCreateMode === 'existing' ? ( + <> + Uses branch{' '} + {worktreeTargetBranch} + {' '}at{' '} + + {formatPathForDisplay(worktreePreviewPath, homeDirectory)} + + + ) : ( + <> + Creates branch{' '} + {worktreeTargetBranch} + {' '}from{' '} + {selectedWorktreeBaseLabel} + {' '}at{' '} + + {formatPathForDisplay(worktreePreviewPath, homeDirectory)} + + + )} +

+ ) : null}
{worktreeError &&

{worktreeError}

} diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index b9755cb5..d5496fd6 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -31,14 +31,14 @@ function SelectTrigger({ children, ...props }: React.ComponentProps & { - size?: "sm" | "default" + size?: "sm" | "default" | "lg" }) { return ( { - const { projectDirectory, worktreeSlug, branch, createBranch } = options; + const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options; const normalizedProject = normalize(projectDirectory); const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug); @@ -92,6 +93,7 @@ export async function createWorktree(options: CreateWorktreeOptions): Promise