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,
|
||||
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<SortableProjectItemProps> = ({
|
||||
onHoverChange,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onOpenBranchPicker,
|
||||
onOpenMultiRunLauncher,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
@@ -257,7 +262,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isRepo && onNewWorktreeSession && !settingsAutoCreateWorktree && (
|
||||
{isRepo && !hideDirectoryControls && onNewWorktreeSession && !settingsAutoCreateWorktree && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -267,7 +272,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onNewWorktreeSession();
|
||||
}}
|
||||
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',
|
||||
)}
|
||||
aria-label="New session in worktree"
|
||||
@@ -280,6 +285,29 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
</TooltipContent>
|
||||
</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>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -367,6 +395,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||
@@ -1592,6 +1621,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
createWorktreeSession();
|
||||
}}
|
||||
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
@@ -1627,6 +1657,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<BranchPickerDialog
|
||||
open={branchPickerOpen}
|
||||
onOpenChange={setBranchPickerOpen}
|
||||
projects={normalizedProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user