mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: Implement worktree initialization script functionality
This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include: 1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts. 2. **Worktree Routes**: Updated worktree routes to include init script handling. 3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility. 4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view. 5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI. This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows.
This commit is contained in:
@@ -78,6 +78,8 @@ import {
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
@@ -255,6 +257,9 @@ export function BoardView() {
|
||||
// Window state hook for compact dialog mode
|
||||
const { isMaximized } = useWindowState();
|
||||
|
||||
// Init script events hook - subscribe to worktree init script events
|
||||
useInitScriptEvents(currentProject?.path ?? null);
|
||||
|
||||
// Keyboard shortcuts hook will be initialized after actions hook
|
||||
|
||||
// Prevent hydration issues
|
||||
@@ -1570,6 +1575,9 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||
<InitScriptIndicator projectPath={currentProject.path} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useRef, useEffect } 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;
|
||||
}
|
||||
|
||||
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
|
||||
const initScriptState = useAppStore((s) => s.initScriptState[projectPath]);
|
||||
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new output arrives
|
||||
useEffect(() => {
|
||||
if (showLogs && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [initScriptState?.output, showLogs]);
|
||||
|
||||
// Reset dismissed state when a new script starts
|
||||
useEffect(() => {
|
||||
if (initScriptState?.status === 'running') {
|
||||
setDismissed(false);
|
||||
setShowLogs(true);
|
||||
}
|
||||
}, [initScriptState?.status]);
|
||||
|
||||
if (!initScriptState || dismissed) return null;
|
||||
if (initScriptState.status === 'idle') return null;
|
||||
|
||||
const { status, output, branch, error } = initScriptState;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
// Clear state after a delay to allow for future scripts
|
||||
setTimeout(() => {
|
||||
clearInitScriptState(projectPath);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4 z-50',
|
||||
'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={handleDismiss}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
|
||||
import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { WorktreesSection } from './settings-view/worktrees';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { AccountSection } from './settings-view/account';
|
||||
import { SecuritySection } from './settings-view/security';
|
||||
@@ -149,17 +150,22 @@ export function SettingsView() {
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
enableDependencyBlocking={enableDependencyBlocking}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
useWorktrees={useWorktrees}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
/>
|
||||
);
|
||||
case 'worktrees':
|
||||
return (
|
||||
<WorktreesSection
|
||||
useWorktrees={useWorktrees}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
/>
|
||||
);
|
||||
case 'account':
|
||||
return <AccountSection />;
|
||||
case 'security':
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
MessageSquareText,
|
||||
User,
|
||||
Shield,
|
||||
Cpu,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
@@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
items: [
|
||||
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
||||
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
ClipboardList,
|
||||
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
useWorktrees: boolean;
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
}
|
||||
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
|
||||
defaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
useWorktrees,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onSkipVerificationInAutoModeChange,
|
||||
onUseWorktreesChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
@@ -257,32 +252,6 @@ export function FeatureDefaultsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Worktree Isolation Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-worktrees"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Creates isolated git branches for each feature. When disabled, agents work directly in
|
||||
the main project directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SettingsViewId =
|
||||
| 'keyboard'
|
||||
| 'audio'
|
||||
| 'defaults'
|
||||
| 'worktrees'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'danger';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WorktreesSection } from './worktrees-section';
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiPost, apiPut } from '@/lib/api-fetch';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreesSectionProps {
|
||||
useWorktrees: boolean;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface InitScriptResponse {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [scriptPath, setScriptPath] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load init script content when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) {
|
||||
setScriptContent('');
|
||||
setScriptPath('');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitScript = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiPost<InitScriptResponse>('/api/worktree/init-script', {
|
||||
projectPath: currentProject.path,
|
||||
});
|
||||
if (response.success) {
|
||||
setScriptContent(response.content || '');
|
||||
setScriptPath(response.path || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load init script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitScript();
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Debounced save function
|
||||
const saveScript = useCallback(
|
||||
async (content: string) => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||
'/api/worktree/init-script',
|
||||
{
|
||||
projectPath: currentProject.path,
|
||||
content,
|
||||
}
|
||||
);
|
||||
if (response.success) {
|
||||
setShowSaved(true);
|
||||
savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000);
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
[currentProject?.path]
|
||||
);
|
||||
|
||||
// Handle content change with debounce
|
||||
const handleContentChange = useCallback(
|
||||
(value: string) => {
|
||||
setScriptContent(value);
|
||||
setShowSaved(false);
|
||||
|
||||
// Clear existing timeouts
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
if (savedTimeoutRef.current) {
|
||||
clearTimeout(savedTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce save
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveScript(value);
|
||||
}, 1000);
|
||||
},
|
||||
[saveScript]
|
||||
);
|
||||
|
||||
// Cleanup timeouts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<GitBranch className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure git worktree isolation and initialization scripts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Enable Worktrees Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-worktrees"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Creates isolated git branches for each feature. When disabled, agents work directly in
|
||||
the main project directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Init Script Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-medium">Initialization Script</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{isSaving && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
{showSaved && !isSaving && (
|
||||
<span className="flex items-center gap-1 text-green-500">
|
||||
<Check className="w-3 h-3" />
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git
|
||||
Bash on Windows for cross-platform compatibility.
|
||||
</p>
|
||||
|
||||
{currentProject ? (
|
||||
<>
|
||||
{/* File path indicator */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
<code className="font-mono">.automaker/worktree-init.sh</code>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<ShellSyntaxEditor
|
||||
value={scriptContent}
|
||||
onChange={handleContentChange}
|
||||
placeholder={`# Example initialization commands
|
||||
npm install
|
||||
|
||||
# Or use pnpm
|
||||
# pnpm install
|
||||
|
||||
# Copy environment file
|
||||
# cp .env.example .env`}
|
||||
minHeight="200px"
|
||||
data-testid="init-script-editor"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground/60 py-4 text-center">
|
||||
Select a project to configure the init script.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user