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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user