feat(terminal): add task worktrees section and remove terminal limit (#1033)

* feat(terminal): add task worktrees section and remove terminal limit

- Remove 12 terminal worktree limit (now unlimited)
- Add "Task Worktrees" section in worktree dropdown below terminal worktrees
- Task worktrees (created by kanban) now accessible for manual work
- Update translations for new section labels (EN + FR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): address PR review feedback

- Clear taskWorktrees state when project is null or changes
- Parallelize API calls with Promise.all for better performance
- Use consistent path-based filtering for both worktree types
- Add clarifying comment for createdAt timestamp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-01-13 20:44:48 +01:00
committed by AndyMik90
parent df1b8a3f0f
commit 17118b0711
5 changed files with 128 additions and 58 deletions
@@ -138,7 +138,7 @@ function isValidProjectPath(projectPath: string): boolean {
return projects.some(p => p.path === projectPath);
}
const MAX_TERMINAL_WORKTREES = 12;
// No limit on terminal worktrees - users can create as many as needed
/**
* Get the default branch from project settings OR env config
@@ -267,14 +267,6 @@ async function createTerminalWorktree(
};
}
const existing = await listTerminalWorktrees(projectPath);
if (existing.length >= MAX_TERMINAL_WORKTREES) {
return {
success: false,
error: `Maximum of ${MAX_TERMINAL_WORKTREES} terminal worktrees reached.`,
};
}
// Auto-fix any misconfigured bare repo before worktree operations
// This prevents crashes when git worktree operations have incorrectly set bare=true
if (fixMisconfiguredBareRepo(projectPath)) {
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { FolderGit, Plus, ChevronDown, Loader2, Trash2 } from 'lucide-react';
import { FolderGit, Plus, ChevronDown, Loader2, Trash2, ListTodo } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TerminalWorktreeConfig } from '../../../shared/types';
import type { TerminalWorktreeConfig, WorktreeListItem } from '../../../shared/types';
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +20,7 @@ import {
AlertDialogTitle,
} from '../ui/alert-dialog';
import { cn } from '../../lib/utils';
import { useProjectStore } from '../../stores/project-store';
interface WorktreeSelectorProps {
terminalId: string;
@@ -33,7 +34,7 @@ interface WorktreeSelectorProps {
}
export function WorktreeSelector({
terminalId: _terminalId,
terminalId,
projectPath,
currentWorktree,
onCreateWorktree,
@@ -41,24 +42,48 @@ export function WorktreeSelector({
}: WorktreeSelectorProps) {
const { t } = useTranslation(['terminal', 'common']);
const [worktrees, setWorktrees] = useState<TerminalWorktreeConfig[]>([]);
const [taskWorktrees, setTaskWorktrees] = useState<WorktreeListItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [deleteWorktree, setDeleteWorktree] = useState<TerminalWorktreeConfig | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Get project ID from projectPath for task worktrees API
const project = useProjectStore((state) =>
state.projects.find((p) => p.path === projectPath)
);
// Fetch worktrees when dropdown opens
const fetchWorktrees = async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const result = await window.electronAPI.listTerminalWorktrees(projectPath);
if (result.success && result.data) {
// Filter out the current worktree from the list
// Fetch terminal worktrees and task worktrees in parallel
const [terminalResult, taskResult] = await Promise.all([
window.electronAPI.listTerminalWorktrees(projectPath),
project?.id ? window.electronAPI.listWorktrees(project.id) : Promise.resolve(null),
]);
// Process terminal worktrees
if (terminalResult.success && terminalResult.data) {
// Filter out the current worktree from the list using path for consistency
const available = currentWorktree
? result.data.filter((wt) => wt.name !== currentWorktree.name)
: result.data;
? terminalResult.data.filter((wt) => wt.worktreePath !== currentWorktree.worktreePath)
: terminalResult.data;
setWorktrees(available);
}
// Process task worktrees
if (taskResult?.success && taskResult.data?.worktrees) {
// Filter out current worktree if it matches a task worktree
const availableTaskWorktrees = currentWorktree
? taskResult.data.worktrees.filter((wt) => wt.path !== currentWorktree.worktreePath)
: taskResult.data.worktrees;
setTaskWorktrees(availableTaskWorktrees);
} else {
// Clear task worktrees when project is null or fetch failed
setTaskWorktrees([]);
}
} catch (err) {
console.error('Failed to fetch worktrees:', err);
} finally {
@@ -66,11 +91,26 @@ export function WorktreeSelector({
}
};
// Convert task worktree to terminal worktree config for selection
const selectTaskWorktree = (taskWt: WorktreeListItem) => {
const config: TerminalWorktreeConfig = {
name: taskWt.specName,
worktreePath: taskWt.path,
branchName: taskWt.branch,
baseBranch: taskWt.baseBranch,
hasGitBranch: true,
// Note: This represents when the worktree was attached to this terminal, not when it was originally created
createdAt: new Date().toISOString(),
terminalId,
};
onSelectWorktree(config);
};
useEffect(() => {
if (isOpen && projectPath) {
fetchWorktrees();
}
}, [isOpen, projectPath, currentWorktree]);
}, [isOpen, projectPath, currentWorktree, project?.id]);
// Handle delete worktree
const handleDeleteWorktree = async () => {
@@ -137,46 +177,82 @@ export function WorktreeSelector({
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</>
) : worktrees.length > 0 ? (
) : (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.existing')}
</div>
{worktrees.map((wt) => (
<DropdownMenuItem
key={wt.name}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
onSelectWorktree(wt);
}}
className="text-xs group"
>
<FolderGit className="h-3 w-3 mr-2 text-amber-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.name}</span>
{wt.branchName && (
<span className="text-[10px] text-muted-foreground truncate">
{wt.branchName}
</span>
)}
{/* Terminal Worktrees Section */}
{worktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.existing')}
</div>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteWorktree(wt);
}}
className="ml-2 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title={t('common:delete')}
>
<Trash2 className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
{worktrees.map((wt) => (
<DropdownMenuItem
key={wt.name}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
onSelectWorktree(wt);
}}
className="text-xs group"
>
<FolderGit className="h-3 w-3 mr-2 text-amber-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.name}</span>
{wt.branchName && (
<span className="text-[10px] text-muted-foreground truncate">
{wt.branchName}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteWorktree(wt);
}}
className="ml-2 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title={t('common:delete')}
>
<Trash2 className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
</>
)}
{/* Task Worktrees Section */}
{taskWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.taskWorktrees')}
</div>
{taskWorktrees.map((wt) => (
<DropdownMenuItem
key={wt.specName}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
selectTaskWorktree(wt);
}}
className="text-xs group"
>
<ListTodo className="h-3 w-3 mr-2 text-cyan-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.specName}</span>
{wt.branch && (
<span className="text-[10px] text-muted-foreground truncate">
{wt.branch}
</span>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
</>
) : null}
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -70,7 +70,7 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
terminals: [],
layouts: [],
activeTerminalId: null,
maxTerminals: 12,
maxTerminals: Infinity, // No limit on terminals
hasRestoredSessions: false,
addTerminal: (cwd?: string, projectPath?: string) => {
@@ -10,7 +10,8 @@
"worktree": {
"create": "Worktree",
"createNew": "New Worktree",
"existing": "Existing Worktrees",
"existing": "Terminal Worktrees",
"taskWorktrees": "Task Worktrees",
"createTitle": "Create Terminal Worktree",
"createDescription": "Create an isolated workspace for this terminal. All work will happen in the worktree directory.",
"name": "Worktree Name",
@@ -10,7 +10,8 @@
"worktree": {
"create": "Worktree",
"createNew": "Nouveau Worktree",
"existing": "Worktrees Existants",
"existing": "Worktrees Terminal",
"taskWorktrees": "Worktrees de Taches",
"createTitle": "Creer un Worktree Terminal",
"createDescription": "Creer un espace de travail isole pour ce terminal. Tout le travail se fera dans le repertoire du worktree.",
"name": "Nom du Worktree",