feat: add branch picker dialog for creating worktree sessions (#160)
- Add BranchPickerDialog component to browse branches across all projects - Filter out branches that already have worktrees - Show commit hash and ahead/behind indicators for each branch - Add createWorktreeSessionForBranch() for specific project/branch - Add worktree and branch picker buttons in sidebar project header Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
033e370c55
commit
7aa4d79b1d
@@ -0,0 +1,299 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
RiGitBranchLine,
|
||||||
|
RiSearchLine,
|
||||||
|
RiFolderLine,
|
||||||
|
RiAddLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiLoader4Line,
|
||||||
|
} from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getGitBranches, listGitWorktrees } from '@/lib/gitApi';
|
||||||
|
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||||
|
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
normalizedPath: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchPickerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
projects: Project[];
|
||||||
|
activeProjectId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectBranchData {
|
||||||
|
branches: GitBranch | null;
|
||||||
|
worktrees: GitWorktreeInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchPickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
projects,
|
||||||
|
activeProjectId,
|
||||||
|
}: BranchPickerDialogProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const [projectData, setProjectData] = React.useState<Map<string, ProjectBranchData>>(new Map());
|
||||||
|
const [expandedProjects, setExpandedProjects] = React.useState<Set<string>>(new Set());
|
||||||
|
const [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearchQuery('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProject = projects.find(p => p.id === activeProjectId);
|
||||||
|
if (activeProject) {
|
||||||
|
setExpandedProjects(new Set([activeProject.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.forEach(async (project) => {
|
||||||
|
setProjectData(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(project.id, { branches: null, worktrees: [], loading: true, error: null });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [branches, worktrees] = await Promise.all([
|
||||||
|
getGitBranches(project.path),
|
||||||
|
listGitWorktrees(project.path),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setProjectData(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(project.id, { branches, worktrees, loading: false, error: null });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setProjectData(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(project.id, {
|
||||||
|
branches: null,
|
||||||
|
worktrees: [],
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to load',
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [open, projects, activeProjectId]);
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setExpandedProjects(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(projectId)) {
|
||||||
|
next.delete(projectId);
|
||||||
|
} else {
|
||||||
|
next.add(projectId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWorktree = async (project: Project, branchName: string) => {
|
||||||
|
const key = `${project.id}:${branchName}`;
|
||||||
|
setCreatingWorktree(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createWorktreeSessionForBranch(project.path, branchName);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create worktree:', err);
|
||||||
|
} finally {
|
||||||
|
setCreatingWorktree(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterBranches = (branches: string[], query: string): string[] => {
|
||||||
|
if (!query.trim()) return branches;
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return branches.filter(b => b.toLowerCase().includes(lowerQuery));
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitRepoProjects = projects.filter(p => {
|
||||||
|
const data = projectData.get(p.id);
|
||||||
|
return data && !data.error && (data.loading || data.branches);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-xl max-h-[80vh] flex flex-col overflow-hidden gap-3">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>Branches & Worktrees</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search branches..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gitRepoProjects.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No git repositories found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
gitRepoProjects.map((project) => {
|
||||||
|
const data = projectData.get(project.id);
|
||||||
|
const isExpanded = expandedProjects.has(project.id);
|
||||||
|
const branches = data?.branches;
|
||||||
|
const worktrees = data?.worktrees || [];
|
||||||
|
const worktreeBranches = new Set(worktrees.map(w => w.branch).filter(Boolean));
|
||||||
|
|
||||||
|
const allBranches = branches?.all || [];
|
||||||
|
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||||
|
const localBranches = filteredBranches
|
||||||
|
.filter(b => !b.startsWith('remotes/'))
|
||||||
|
.filter(b => !worktreeBranches.has(b));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={project.id} className="rounded-lg border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleProject(project.id)}
|
||||||
|
className="w-full flex items-center gap-2 p-3 hover:bg-muted/50 transition-colors rounded-t-lg"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<RiFolderLine className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-sm truncate flex-1 text-left">
|
||||||
|
{project.label || project.normalizedPath.split('/').pop() || project.normalizedPath}
|
||||||
|
</span>
|
||||||
|
{data?.loading && (
|
||||||
|
<RiLoader4Line className="h-4 w-4 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
{branches && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{localBranches.length} branches
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t">
|
||||||
|
{data?.loading ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||||
|
Loading branches...
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<div className="p-4 text-center text-destructive text-sm">
|
||||||
|
{data.error}
|
||||||
|
</div>
|
||||||
|
) : localBranches.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||||
|
{searchQuery ? 'No matching branches' : 'No branches found'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y overflow-hidden">
|
||||||
|
{localBranches.map((branchName) => {
|
||||||
|
const branchDetails = branches?.branches[branchName];
|
||||||
|
const isCurrent = branchDetails?.current;
|
||||||
|
const isCreating = creatingWorktree === `${project.id}:${branchName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={branchName}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-muted/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm truncate',
|
||||||
|
isCurrent && 'font-medium text-primary'
|
||||||
|
)}>
|
||||||
|
{branchName}
|
||||||
|
</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||||
|
current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{branchDetails?.commit && (
|
||||||
|
<span className="font-mono">
|
||||||
|
{branchDetails.commit.slice(0, 7)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && (
|
||||||
|
<span className="text-[color:var(--status-success)]">
|
||||||
|
↑{branchDetails.ahead}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{branchDetails?.behind !== undefined && branchDetails.behind > 0 && (
|
||||||
|
<span className="text-[color:var(--status-warning)]">
|
||||||
|
↓{branchDetails.behind}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCreateWorktree(project, branchName)}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="inline-flex h-7 px-2 items-center justify-center text-xs rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiAddLine className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Worktree
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
Create worktree for this branch
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
|
RiGitRepositoryLine,
|
||||||
RiLinkUnlinkM,
|
RiLinkUnlinkM,
|
||||||
|
|
||||||
RiMore2Line,
|
RiMore2Line,
|
||||||
RiPencilAiLine,
|
RiPencilAiLine,
|
||||||
RiShare2Line,
|
RiShare2Line,
|
||||||
@@ -59,6 +61,7 @@ import { checkIsGitRepository } from '@/lib/gitApi';
|
|||||||
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||||
|
|
||||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||||
@@ -135,6 +138,7 @@ interface SortableProjectItemProps {
|
|||||||
onHoverChange: (hovered: boolean) => void;
|
onHoverChange: (hovered: boolean) => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
|
onOpenBranchPicker?: () => void;
|
||||||
onOpenMultiRunLauncher: () => void;
|
onOpenMultiRunLauncher: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||||
@@ -158,6 +162,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onHoverChange,
|
onHoverChange,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
|
onOpenBranchPicker,
|
||||||
onOpenMultiRunLauncher,
|
onOpenMultiRunLauncher,
|
||||||
onClose,
|
onClose,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
@@ -257,7 +262,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{isRepo && onNewWorktreeSession && !settingsAutoCreateWorktree && (
|
{isRepo && !hideDirectoryControls && onNewWorktreeSession && !settingsAutoCreateWorktree && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -267,7 +272,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onNewWorktreeSession();
|
onNewWorktreeSession();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground flex-shrink-0',
|
||||||
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
|
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
|
||||||
)}
|
)}
|
||||||
aria-label="New session in worktree"
|
aria-label="New session in worktree"
|
||||||
@@ -280,6 +285,29 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
||||||
|
<Tooltip delayDuration={700}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenBranchPicker();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||||
|
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
|
||||||
|
)}
|
||||||
|
aria-label="Browse branches"
|
||||||
|
>
|
||||||
|
<RiGitRepositoryLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" sideOffset={4}>
|
||||||
|
<p>Browse branches</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -367,6 +395,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||||
|
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||||
@@ -1592,6 +1621,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
createWorktreeSession();
|
createWorktreeSession();
|
||||||
}}
|
}}
|
||||||
|
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||||
onOpenMultiRunLauncher={() => {
|
onOpenMultiRunLauncher={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProject(projectKey);
|
||||||
@@ -1627,6 +1657,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
|
||||||
|
<BranchPickerDialog
|
||||||
|
open={branchPickerOpen}
|
||||||
|
onOpenChange={setBranchPickerOpen}
|
||||||
|
projects={normalizedProjects}
|
||||||
|
activeProjectId={activeProjectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,3 +249,192 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
|||||||
export function isCreatingWorktree(): boolean {
|
export function isCreatingWorktree(): boolean {
|
||||||
return isCreatingWorktreeSession;
|
return isCreatingWorktreeSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session with a worktree for a specific branch.
|
||||||
|
* Unlike createWorktreeSession(), this allows specifying the project and branch explicitly.
|
||||||
|
*
|
||||||
|
* @param projectDirectory - The root directory of the git repository
|
||||||
|
* @param branchName - The name of the branch to create a worktree for
|
||||||
|
* @returns The created session, or null if creation failed
|
||||||
|
*/
|
||||||
|
export async function createWorktreeSessionForBranch(
|
||||||
|
projectDirectory: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<{ id: string } | null> {
|
||||||
|
if (isCreatingWorktreeSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a git repo
|
||||||
|
let isGitRepo = false;
|
||||||
|
try {
|
||||||
|
isGitRepo = await checkIsGitRepository(projectDirectory);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors, treat as not a git repo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGitRepo) {
|
||||||
|
toast.error('Not a Git repository', {
|
||||||
|
description: 'Worktrees can only be created in Git repositories.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingWorktreeSession = true;
|
||||||
|
startConfigUpdate("Creating worktree session...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the branch name as the worktree slug (sanitized)
|
||||||
|
const worktreeSlug = sanitizeWorktreeSlug(branchName);
|
||||||
|
|
||||||
|
// Create the worktree - don't create a new branch, use existing one
|
||||||
|
const metadata = await createWorktree({
|
||||||
|
projectDirectory,
|
||||||
|
worktreeSlug,
|
||||||
|
branch: branchName,
|
||||||
|
createBranch: false, // Use existing branch
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get worktree status
|
||||||
|
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||||
|
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||||
|
|
||||||
|
// Create the session
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||||
|
if (!session) {
|
||||||
|
// Clean up the worktree if session creation failed
|
||||||
|
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
|
||||||
|
toast.error('Failed to create session', {
|
||||||
|
description: 'Could not create a session for the worktree.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the session
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
const agents = configState.agents;
|
||||||
|
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||||
|
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||||
|
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||||
|
|
||||||
|
// Apply default agent and model settings
|
||||||
|
try {
|
||||||
|
const visibleAgents = configState.getVisibleAgents();
|
||||||
|
let agentName: string | undefined;
|
||||||
|
|
||||||
|
// Priority: settingsDefaultAgent → build → first visible
|
||||||
|
if (configState.settingsDefaultAgent) {
|
||||||
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||||
|
if (settingsAgent) {
|
||||||
|
agentName = settingsAgent.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!agentName) {
|
||||||
|
agentName =
|
||||||
|
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||||
|
visibleAgents[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName) {
|
||||||
|
// 1. Update global UI state
|
||||||
|
configState.setAgent(agentName);
|
||||||
|
|
||||||
|
// 2. Persist to session context so it sticks after reload/switch
|
||||||
|
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
|
||||||
|
|
||||||
|
// 3. Handle default model for the agent if set in global settings
|
||||||
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||||
|
if (settingsDefaultModel) {
|
||||||
|
const parts = settingsDefaultModel.split('/');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [providerId, modelId] = parts;
|
||||||
|
// Validate model exists (optional, but good practice)
|
||||||
|
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||||
|
if (modelMetadata) {
|
||||||
|
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
|
||||||
|
// Also save the specific agent's model preference for this session
|
||||||
|
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
|
||||||
|
|
||||||
|
// Seed default variant into session context so ModelControls restore logic
|
||||||
|
// doesn't wipe it on first switch to the new session.
|
||||||
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||||
|
if (settingsDefaultVariant) {
|
||||||
|
const provider = configState.providers.find((p) => p.id === providerId);
|
||||||
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelId) as
|
||||||
|
| { variants?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
const variants = model?.variants;
|
||||||
|
|
||||||
|
if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||||
|
configState.setCurrentVariant(settingsDefaultVariant);
|
||||||
|
useContextStore
|
||||||
|
.getState()
|
||||||
|
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors setting default agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update directory
|
||||||
|
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||||
|
|
||||||
|
// Refresh sessions list
|
||||||
|
try {
|
||||||
|
await sessionStore.loadSessions();
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and run setup commands
|
||||||
|
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
|
||||||
|
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||||
|
|
||||||
|
if (commandsToRun.length > 0) {
|
||||||
|
toast.success('Worktree created', {
|
||||||
|
description: `Branch: ${branchName}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run setup commands in background
|
||||||
|
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Setup commands completed', {
|
||||||
|
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const failed = result.results.filter(r => !r.success);
|
||||||
|
const succeeded = result.results.filter(r => r.success);
|
||||||
|
toast.error('Setup commands failed', {
|
||||||
|
description: `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
|
||||||
|
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
toast.error('Setup commands failed', {
|
||||||
|
description: 'Could not execute setup commands.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Worktree created', {
|
||||||
|
description: `Branch: ${branchName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||||
|
toast.error('Failed to create worktree', {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
finishConfigUpdate();
|
||||||
|
isCreatingWorktreeSession = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user