From 7aa4d79b1d9919fc5c9e792452cd6d1786ec6482 Mon Sep 17 00:00:00 2001 From: Nils <104727469+nilskroe@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:54:23 +0100 Subject: [PATCH] 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 --- .../components/session/BranchPickerDialog.tsx | 299 ++++++++++++++++++ .../src/components/session/SessionSidebar.tsx | 40 ++- packages/ui/src/lib/worktreeSessionCreator.ts | 189 +++++++++++ 3 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/session/BranchPickerDialog.tsx diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx new file mode 100644 index 00000000..1574f867 --- /dev/null +++ b/packages/ui/src/components/session/BranchPickerDialog.tsx @@ -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>(new Map()); + const [expandedProjects, setExpandedProjects] = React.useState>(new Set()); + const [creatingWorktree, setCreatingWorktree] = React.useState(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 ( + + + + Branches & Worktrees + + +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+
+ {gitRepoProjects.length === 0 ? ( +
+ No git repositories found +
+ ) : ( + 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 ( +
+ + + {isExpanded && ( +
+ {data?.loading ? ( +
+ Loading branches... +
+ ) : data?.error ? ( +
+ {data.error} +
+ ) : localBranches.length === 0 ? ( +
+ {searchQuery ? 'No matching branches' : 'No branches found'} +
+ ) : ( +
+ {localBranches.map((branchName) => { + const branchDetails = branches?.branches[branchName]; + const isCurrent = branchDetails?.current; + const isCreating = creatingWorktree === `${project.id}:${branchName}`; + + return ( +
+ +
+
+ + {branchName} + + {isCurrent && ( + + current + + )} +
+
+ {branchDetails?.commit && ( + + {branchDetails.commit.slice(0, 7)} + + )} + {branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && ( + + ↑{branchDetails.ahead} + + )} + {branchDetails?.behind !== undefined && branchDetails.behind > 0 && ( + + ↓{branchDetails.behind} + + )} +
+
+ + + + + + + Create worktree for this branch + + +
+ ); + })} +
+ )} +
+ )} +
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 7ee8db03..46b87686 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -39,7 +39,9 @@ import { RiFileCopyLine, RiFolderAddLine, RiGitBranchLine, + RiGitRepositoryLine, RiLinkUnlinkM, + RiMore2Line, RiPencilAiLine, RiShare2Line, @@ -59,6 +61,7 @@ import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { BranchPickerDialog } from './BranchPickerDialog'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; @@ -135,6 +138,7 @@ interface SortableProjectItemProps { onHoverChange: (hovered: boolean) => void; onNewSession: () => void; onNewWorktreeSession?: () => void; + onOpenBranchPicker?: () => void; onOpenMultiRunLauncher: () => void; onClose: () => void; sentinelRef: (el: HTMLDivElement | null) => void; @@ -158,6 +162,7 @@ const SortableProjectItem: React.FC = ({ onHoverChange, onNewSession, onNewWorktreeSession, + onOpenBranchPicker, onOpenMultiRunLauncher, onClose, sentinelRef, @@ -257,7 +262,7 @@ const SortableProjectItem: React.FC = ({ - {isRepo && onNewWorktreeSession && !settingsAutoCreateWorktree && ( + {isRepo && !hideDirectoryControls && onNewWorktreeSession && !settingsAutoCreateWorktree && ( + + +

Browse branches

+
+
+ )}