import { useState, useEffect, useCallback, useRef } from 'react'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Slider } from '@/components/ui/slider'; import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; import { GitBranch, Terminal, FileCode, Save, RotateCcw, Trash2, PanelBottomClose, Copy, Plus, FolderOpen, LayoutGrid, Pin, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { Project } from '@/lib/electron'; import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog'; // Stable empty array reference to prevent unnecessary re-renders when no copy files are set const EMPTY_FILES: string[] = []; interface WorktreePreferencesSectionProps { project: Project; } interface InitScriptResponse { success: boolean; exists: boolean; content: string; path: string; error?: string; } export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) { // Use direct store subscriptions (not getter functions) so the component // properly re-renders when these values change in the store. const globalUseWorktrees = useAppStore((s) => s.useWorktrees); const projectUseWorktrees = useAppStore((s) => s.useWorktreesByProject[project.path]); const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees); const showIndicator = useAppStore( (s) => s.showInitScriptIndicatorByProject[project.path] ?? true ); const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); const defaultDeleteBranch = useAppStore( (s) => s.defaultDeleteBranchByProject[project.path] ?? false ); const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); const autoDismiss = useAppStore( (s) => s.autoDismissInitScriptIndicatorByProject[project.path] ?? true ); const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); // Use a stable empty array reference to prevent new array on every render when // worktreeCopyFilesByProject[project.path] is undefined (not yet loaded). const copyFilesFromStore = useAppStore((s) => s.worktreeCopyFilesByProject[project.path]); const copyFiles = copyFilesFromStore ?? EMPTY_FILES; const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles); // Worktree display settings const pinnedWorktreesCount = useAppStore((s) => s.getPinnedWorktreesCount(project.path)); const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount); // Get effective worktrees setting (project override or global fallback) const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees; const [scriptContent, setScriptContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [scriptExists, setScriptExists] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); // Copy files state const [newCopyFilePath, setNewCopyFilePath] = useState(''); const [fileSelectorOpen, setFileSelectorOpen] = useState(false); // Ref for storing previous slider value for rollback on error const sliderPrevRef = useRef(null); // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; // Load project settings (including useWorktrees) when project changes useEffect(() => { let isCancelled = false; const currentPath = project.path; const loadProjectSettings = async () => { try { const httpClient = getHttpApiClient(); const response = await httpClient.settings.getProject(currentPath); // Avoid updating state if component unmounted or project changed if (isCancelled) return; if (response.success && response.settings) { // Sync useWorktrees to store if it has a value if (response.settings.useWorktrees !== undefined) { setProjectUseWorktrees(currentPath, response.settings.useWorktrees); } // Also sync other settings to store if (response.settings.showInitScriptIndicator !== undefined) { setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator); } if (response.settings.defaultDeleteBranchWithWorktree !== undefined) { setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree); } if (response.settings.autoDismissInitScriptIndicator !== undefined) { setAutoDismissInitScriptIndicator( currentPath, response.settings.autoDismissInitScriptIndicator ); } if (response.settings.worktreeCopyFiles !== undefined) { setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles); } if (response.settings.pinnedWorktreesCount !== undefined) { setPinnedWorktreesCount(currentPath, response.settings.pinnedWorktreesCount); } } } catch (error) { if (!isCancelled) { console.error('Failed to load project settings:', error); } } }; loadProjectSettings(); return () => { isCancelled = true; }; }, [ project.path, setProjectUseWorktrees, setShowInitScriptIndicator, setDefaultDeleteBranch, setAutoDismissInitScriptIndicator, setWorktreeCopyFiles, setPinnedWorktreesCount, ]); // Load init script content when project changes useEffect(() => { let isCancelled = false; const currentPath = project.path; const loadInitScript = async () => { setIsLoading(true); try { const response = await apiGet( `/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}` ); // Avoid updating state if component unmounted or project changed if (isCancelled) return; if (response.success) { const content = response.content || ''; setScriptContent(content); setOriginalContent(content); setScriptExists(response.exists); } } catch (error) { if (!isCancelled) { console.error('Failed to load init script:', error); } } finally { if (!isCancelled) { setIsLoading(false); } } }; loadInitScript(); return () => { isCancelled = true; }; }, [project.path]); // Save script const handleSave = useCallback(async () => { setIsSaving(true); try { const response = await apiPut<{ success: boolean; error?: string }>( '/api/worktree/init-script', { projectPath: project.path, content: scriptContent, } ); if (response.success) { setOriginalContent(scriptContent); setScriptExists(true); toast.success('Init script saved'); } else { toast.error('Failed to save init script', { description: response.error, }); } } catch (error) { console.error('Failed to save init script:', error); toast.error('Failed to save init script'); } finally { setIsSaving(false); } }, [project.path, scriptContent]); // Reset to original content const handleReset = useCallback(() => { setScriptContent(originalContent); }, [originalContent]); // Delete script const handleDelete = useCallback(async () => { setIsDeleting(true); try { const response = await apiDelete<{ success: boolean; error?: string }>( '/api/worktree/init-script', { body: { projectPath: project.path }, } ); if (response.success) { setScriptContent(''); setOriginalContent(''); setScriptExists(false); toast.success('Init script deleted'); } else { toast.error('Failed to delete init script', { description: response.error, }); } } catch (error) { console.error('Failed to delete init script:', error); toast.error('Failed to delete init script'); } finally { setIsDeleting(false); } }, [project.path]); // Handle content change (no auto-save) const handleContentChange = useCallback((value: string) => { setScriptContent(value); }, []); // Add a new file path to copy list const handleAddCopyFile = useCallback(async () => { const trimmed = newCopyFilePath.trim(); if (!trimmed) return; // Normalize: remove leading ./ or / const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, ''); if (!normalized) return; // Check for duplicates if (copyFiles.includes(normalized)) { toast.error('File already in list', { description: `"${normalized}" is already configured for copying.`, }); return; } const prevFiles = copyFiles; const updatedFiles = [...copyFiles, normalized]; setWorktreeCopyFiles(project.path, updatedFiles); setNewCopyFilePath(''); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { worktreeCopyFiles: updatedFiles, }); toast.success('Copy file added', { description: `"${normalized}" will be copied to new worktrees.`, }); } catch (error) { // Rollback optimistic update on failure setWorktreeCopyFiles(project.path, prevFiles); setNewCopyFilePath(normalized); console.error('Failed to persist worktreeCopyFiles:', error); toast.error('Failed to save copy files setting'); } }, [project.path, newCopyFilePath, copyFiles, setWorktreeCopyFiles]); // Remove a file path from copy list const handleRemoveCopyFile = useCallback( async (filePath: string) => { const prevFiles = copyFiles; const updatedFiles = copyFiles.filter((f) => f !== filePath); setWorktreeCopyFiles(project.path, updatedFiles); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { worktreeCopyFiles: updatedFiles, }); toast.success('Copy file removed'); } catch (error) { // Rollback optimistic update on failure setWorktreeCopyFiles(project.path, prevFiles); console.error('Failed to persist worktreeCopyFiles:', error); toast.error('Failed to save copy files setting'); } }, [project.path, copyFiles, setWorktreeCopyFiles] ); // Handle files selected from the file selector dialog const handleFileSelectorSelect = useCallback( async (paths: string[]) => { // Filter out duplicates const newPaths = paths.filter((p) => !copyFiles.includes(p)); if (newPaths.length === 0) { toast.info('All selected files are already in the list'); return; } const prevFiles = copyFiles; const updatedFiles = [...copyFiles, ...newPaths]; setWorktreeCopyFiles(project.path, updatedFiles); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { worktreeCopyFiles: updatedFiles, }); toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, { description: newPaths.map((p) => `"${p}"`).join(', '), }); } catch (error) { // Rollback optimistic update on failure setWorktreeCopyFiles(project.path, prevFiles); console.error('Failed to persist worktreeCopyFiles:', error); toast.error('Failed to save copy files setting'); } }, [project.path, copyFiles, setWorktreeCopyFiles] ); return (

Worktree Preferences

Configure worktree behavior for this project.

{/* Enable Git Worktree Isolation Toggle */}
{ const value = checked === true; setProjectUseWorktrees(project.path, value); try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { useWorktrees: value, }); } catch (error) { console.error('Failed to persist useWorktrees:', error); } }} className="mt-1" data-testid="project-use-worktrees-checkbox" />

Creates isolated git branches for each feature in this project. When disabled, agents work directly in the main project directory.

{/* Separator */}
{/* Show Init Script Indicator Toggle */}
{ const value = checked === true; setShowInitScriptIndicator(project.path, value); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { showInitScriptIndicator: value, }); } catch (error) { console.error('Failed to persist showInitScriptIndicator:', error); } }} className="mt-1" />

Display a floating panel in the bottom-right corner showing init script execution status and output when a worktree is created.

{/* Auto-dismiss Init Script Indicator Toggle */} {showIndicator && (
{ const value = checked === true; setAutoDismissInitScriptIndicator(project.path, value); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { autoDismissInitScriptIndicator: value, }); } catch (error) { console.error('Failed to persist autoDismissInitScriptIndicator:', error); } }} className="mt-1" />

Automatically hide the indicator 5 seconds after the script completes.

)} {/* Default Delete Branch Toggle */}
{ const value = checked === true; setDefaultDeleteBranch(project.path, value); // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { defaultDeleteBranch: value, }); } catch (error) { console.error('Failed to persist defaultDeleteBranch:', error); } }} className="mt-1" />

When deleting a worktree, automatically check the "Also delete the branch" option.

{/* Separator */}
{/* Worktree Display Settings */}

Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and remaining worktrees are available in a combined overflow dropdown.

{/* Pinned Worktrees Count */}
{pinnedWorktreesCount}

Number of worktree tabs to pin (excluding the main worktree, which is always shown).

{ // Capture previous value before mutation for potential rollback const prevCount = pinnedWorktreesCount; // Update local state immediately for visual feedback const newValue = value[0] ?? pinnedWorktreesCount; setPinnedWorktreesCount(project.path, newValue); // Store prev for onValueCommit rollback sliderPrevRef.current = prevCount; }} onValueCommit={async (value) => { const newValue = value[0] ?? pinnedWorktreesCount; const prev = sliderPrevRef.current ?? pinnedWorktreesCount; // Persist to server try { const httpClient = getHttpApiClient(); await httpClient.settings.updateProject(project.path, { pinnedWorktreesCount: newValue, }); } catch (error) { console.error('Failed to persist pinnedWorktreesCount:', error); toast.error('Failed to save pinned worktrees setting'); // Rollback optimistic update using captured previous value setPinnedWorktreesCount(project.path, prev); } }} className="w-full" />
{/* Separator */}
{/* Copy Files Section */}

Specify files or directories (relative to project root) to automatically copy into new worktrees. Useful for untracked files like{' '} .env,{' '} .env.local, or local config files that aren't committed to git.

{/* Current file list */} {copyFiles.length > 0 && (
{copyFiles.map((filePath) => (
{filePath}
))}
)} {/* Add new file input */}
setNewCopyFilePath(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddCopyFile(); } }} placeholder=".env, config/local.json, etc." className="flex-1 h-8 text-sm font-mono" />
{/* File selector dialog */}
{/* Separator */}
{/* Init Script Section */}

Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash on Windows for cross-platform compatibility.

{/* File path indicator */}
.automaker/worktree-init.sh {hasChanges && (unsaved changes)}
{isLoading ? (
) : ( <> {/* Action buttons */}
)}
); }