Merge pull request #409 from AutoMaker-Org/feat/worktrees-init-script

feat: worktrees init script
This commit is contained in:
Shirone
2026-01-12 17:52:31 +00:00
committed by GitHub
43 changed files with 2859 additions and 117 deletions

View File

@@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
@@ -55,10 +57,22 @@ export function BoardHeader({
completedCount,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
const setPlanUseSelectedWorktreeBranch = useAppStore(
(state) => state.setPlanUseSelectedWorktreeBranch
);
const addFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.addFeatureUseSelectedWorktreeBranch
);
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.setAddFeatureUseSelectedWorktreeBranch
);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Worktree panel visibility (per-project)
@@ -132,9 +146,25 @@ export function BoardHeader({
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
@@ -209,15 +239,33 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
</div>
</div>
);

View File

@@ -56,6 +56,32 @@ import {
const logger = createLogger('AddFeatureDialog');
/**
* Determines the default work mode based on global settings and current worktree selection.
*
* Priority:
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
*/
const getDefaultWorkMode = (
useWorktrees: boolean,
selectedNonMainWorktreeBranch?: string,
forceCurrentBranchMode?: boolean
): WorkMode => {
// If force current branch mode is enabled (worktree setting is off), always use 'current'
if (forceCurrentBranchMode) {
return 'current';
}
// If a non-main worktree is selected, default to 'custom' mode with that branch
if (selectedNonMainWorktreeBranch) {
return 'custom';
}
// Otherwise, respect the global worktree setting
return useWorktrees ? 'auto' : 'current';
};
type FeatureData = {
title: string;
category: string;
@@ -89,6 +115,16 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
allFeatures?: Feature[];
/**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
*/
selectedNonMainWorktreeBranch?: string;
/**
* When true, forces the dialog to default to 'current' work mode (work on current branch).
* This is used when the "Use selected worktree branch" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
}
/**
@@ -112,6 +148,8 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const [workMode, setWorkMode] = useState<WorkMode>('current');
@@ -149,7 +187,7 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -161,8 +199,12 @@ export function AddFeatureDialog({
if (justOpened) {
setSkipTests(defaultSkipTests);
setBranchName(defaultBranch || '');
setWorkMode('current');
// When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
@@ -186,6 +228,9 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
parentFeature,
allFeatures,
]);
@@ -270,10 +315,13 @@ export function AddFeatureDialog({
setImagePaths([]);
setTextFilePaths([]);
setSkipTests(defaultSkipTests);
setBranchName('');
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry({ model: 'opus' });
setWorkMode('current');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map());

View File

@@ -63,6 +63,8 @@ interface BacklogPlanDialogProps {
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
// Branch to use for created features (defaults to main if not provided)
currentBranch?: string;
}
type DialogMode = 'input' | 'review' | 'applying';
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
currentBranch,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
@@ -167,7 +170,7 @@ export function BacklogPlanDialog({
}) || [],
};
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult, currentBranch);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
@@ -184,6 +187,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
onPlanApplied,
onClose,
currentBranch,
]);
const handleDiscard = useCallback(() => {

View File

@@ -10,10 +10,73 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2 } from 'lucide-react';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
/**
* Parse git/worktree error messages and return user-friendly versions
*/
function parseWorktreeError(error: string): { title: string; description?: string } {
const errorLower = error.toLowerCase();
// Worktree already exists
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
return {
title: 'A worktree with this name already exists',
description: 'Try a different branch name or delete the existing worktree first.',
};
}
// Branch already checked out in another worktree
if (
errorLower.includes('already checked out') ||
errorLower.includes('is already used by worktree')
) {
return {
title: 'This branch is already in use',
description: 'The branch is checked out in another worktree. Use a different branch name.',
};
}
// Branch name conflicts with existing branch
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
return {
title: 'A branch with this name already exists',
description: 'The worktree will use the existing branch, or try a different name.',
};
}
// Not a git repository
if (errorLower.includes('not a git repository')) {
return {
title: 'Not a git repository',
description: 'Initialize git in this project first with "git init".',
};
}
// Lock file exists (another git operation in progress)
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
return {
title: 'Another git operation is in progress',
description: 'Wait for it to complete or remove stale lock files.',
};
}
// Permission denied
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
return {
title: 'Permission denied',
description: 'Check file permissions for the project directory.',
};
}
// Default: return original error but cleaned up
return {
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
};
}
interface CreatedWorktreeInfo {
path: string;
branch: string;
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError('Branch name is required');
setError({ title: 'Branch name is required' });
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
setError({
title: 'Invalid branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
return;
}
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError('Worktree API not available');
setError({ title: 'Worktree API not available' });
return;
}
const result = await api.worktree.create(projectPath, branchName);
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
onOpenChange(false);
setBranchName('');
} else {
setError(result.error || 'Failed to create worktree');
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create worktree');
setError(
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
);
} finally {
setIsLoading(false);
}
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground space-y-1">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Default value for the "delete branch" checkbox */
defaultDeleteBranch?: boolean;
}
export function DeleteWorktreeDialog({
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
worktree,
onDeleted,
affectedFeatureCount = 0,
defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [isLoading, setIsLoading] = useState(false);
// Reset deleteBranch to default when dialog opens
useEffect(() => {
if (open) {
setDeleteBranch(defaultDeleteBranch);
}
}, [open, defaultDeleteBranch]);
const handleDelete = async () => {
if (!worktree) return;

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsDialog({
open,
onOpenChange,
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, features created via the Plan dialog will be assigned to the currently
selected worktree branch. When disabled, features will be added to the main branch.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface WorktreeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
addFeatureUseSelectedWorktreeBranch: boolean;
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function WorktreeSettingsDialog({
open,
onOpenChange,
addFeatureUseSelectedWorktreeBranch,
onAddFeatureUseSelectedWorktreeBranchChange,
}: WorktreeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Worktree Settings
</DialogTitle>
<DialogDescription>
Configure how worktrees affect feature creation and organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="worktree-branch-toggle"
checked={addFeatureUseSelectedWorktreeBranch}
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
data-testid="worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, the Add Feature dialog will default to custom branch mode with the
currently selected worktree branch pre-filled.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
interface SingleIndicatorProps {
stateKey: string;
state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
autoDismiss: boolean; // Whether to auto-dismiss after completion
}
function SingleIndicator({
stateKey,
state,
onDismiss,
isOnlyOne,
autoDismiss,
}: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [output, showLogs]);
// Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => {
if (status === 'running' && isOnlyOne) {
setShowLogs(true);
}
}, [status, isOnlyOne]);
// Auto-dismiss after completion (5 seconds)
useEffect(() => {
if (autoDismiss && (status === 'success' || status === 'failed')) {
const timer = setTimeout(() => {
onDismiss(stateKey);
}, 5000);
return () => clearTimeout(timer);
}
}, [status, autoDismiss, stateKey, onDismiss]);
if (status === 'idle') return null;
return (
<div
className={cn(
'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">
Init Script{' '}
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowLogs(!showLogs)}
className="p-1 hover:bg-accent rounded transition-colors"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{showLogs ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{status !== 'running' && (
<button
onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Branch info */}
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
<span>Branch: {branch}</span>
</div>
{/* Logs (collapsible) */}
{showLogs && (
<div className="border-t border-border/50">
<div className="p-3 max-h-[300px] overflow-y-auto">
{output.length > 0 ? (
<AnsiOutput text={output.join('')} />
) : (
<div className="text-xs text-muted-foreground/60 text-center py-2">
{status === 'running' ? 'Waiting for output...' : 'No output'}
</div>
)}
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Status bar for completed states */}
{status !== 'running' && (
<div
className={cn(
'px-3 py-2 text-xs',
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
)}
>
{status === 'success'
? 'Initialization completed successfully'
: 'Initialization failed - worktree is still usable'}
</div>
)}
</div>
);
}
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
// Get auto-dismiss setting
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
autoDismiss={autoDismiss}
/>
))}
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -55,6 +56,8 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
@@ -80,6 +83,8 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -266,6 +271,12 @@ export function WorktreeActionsDropdown({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper

View File

@@ -45,6 +45,8 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeTab({
@@ -85,6 +87,8 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -333,6 +337,8 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>
</div>
);

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -79,6 +81,28 @@ export function WorktreePanel({
features,
});
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -113,6 +137,33 @@ export function WorktreePanel({
}
};
const handleRunInitScript = useCallback(
async (worktree: WorktreeInfo) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.runInitScript(
projectPath,
worktree.path,
worktree.branch
);
if (!result.success) {
toast.error('Failed to run init script', {
description: result.error,
});
}
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
} catch (error) {
toast.error('Failed to run init script', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[projectPath]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -162,6 +213,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
</div>
@@ -216,6 +269,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
);
})}