feat: Enhance ShellSyntaxEditor and WorktreesSection with new features

This commit introduces several improvements to the ShellSyntaxEditor and WorktreesSection components:

1. **ShellSyntaxEditor**: Added a `maxHeight` prop to allow for customizable maximum height, enhancing layout flexibility.
2. **WorktreesSection**:
   - Introduced state management for original script content and existence checks for scripts.
   - Implemented save, reset, and delete functionalities for initialization scripts, providing users with better control over their scripts.
   - Added action buttons for saving, resetting, and deleting scripts, along with loading indicators for improved user feedback.
   - Enhanced UI to indicate unsaved changes, improving user awareness of script modifications.

These changes improve the user experience by providing more robust script management capabilities and a more responsive UI.
This commit is contained in:
Kacper
2026-01-10 22:46:06 +01:00
parent 6c412cd367
commit c24e6207d0
2 changed files with 139 additions and 91 deletions

View File

@@ -13,6 +13,7 @@ interface ShellSyntaxEditorProps {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
minHeight?: string; minHeight?: string;
maxHeight?: string;
'data-testid'?: string; 'data-testid'?: string;
} }
@@ -108,14 +109,12 @@ export function ShellSyntaxEditor({
placeholder, placeholder,
className, className,
minHeight = '200px', minHeight = '200px',
maxHeight,
'data-testid': testId, 'data-testid': testId,
}: ShellSyntaxEditorProps) { }: ShellSyntaxEditorProps) {
return ( return (
<div <div
className={cn( className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
'w-full rounded-lg border border-border bg-muted/30 overflow-hidden',
className
)}
style={{ minHeight }} style={{ minHeight }}
data-testid={testId} data-testid={testId}
> >
@@ -125,7 +124,9 @@ export function ShellSyntaxEditor({
extensions={extensions} extensions={extensions}
theme="none" theme="none"
placeholder={placeholder} placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full [&_.cm-editor]:min-h-[inherit]" height={maxHeight}
minHeight={minHeight}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
foldGutter: false, foldGutter: false,

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react'; import { GitBranch, Terminal, FileCode, Save, RotateCcw, Trash2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiPost, apiPut } from '@/lib/api-fetch'; import { apiPost, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -24,18 +25,21 @@ interface InitScriptResponse {
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject); const currentProject = useAppStore((s) => s.currentProject);
const [scriptContent, setScriptContent] = useState(''); const [scriptContent, setScriptContent] = useState('');
const [scriptPath, setScriptPath] = useState(''); const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showSaved, setShowSaved] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes // Load init script content when project changes
useEffect(() => { useEffect(() => {
if (!currentProject?.path) { if (!currentProject?.path) {
setScriptContent(''); setScriptContent('');
setScriptPath(''); setOriginalContent('');
setScriptExists(false);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -47,8 +51,10 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
projectPath: currentProject.path, projectPath: currentProject.path,
}); });
if (response.success) { if (response.success) {
setScriptContent(response.content || ''); const content = response.content || '';
setScriptPath(response.path || ''); setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
} }
} catch (error) { } catch (error) {
console.error('Failed to load init script:', error); console.error('Failed to load init script:', error);
@@ -60,66 +66,74 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
loadInitScript(); loadInitScript();
}, [currentProject?.path]); }, [currentProject?.path]);
// Debounced save function // Save script
const saveScript = useCallback( const handleSave = useCallback(async () => {
async (content: string) => { if (!currentProject?.path) return;
if (!currentProject?.path) return;
setIsSaving(true); setIsSaving(true);
try { try {
const response = await apiPut<{ success: boolean; error?: string }>( const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script', '/api/worktree/init-script',
{ {
projectPath: currentProject.path, projectPath: currentProject.path,
content, content: scriptContent,
}
);
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); if (response.success) {
toast.error('Failed to save init script'); setOriginalContent(scriptContent);
} finally { setScriptExists(true);
setIsSaving(false); toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
} }
}, } catch (error) {
[currentProject?.path] console.error('Failed to save init script:', error);
); toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Handle content change with debounce // Reset to original content
const handleContentChange = useCallback( const handleReset = useCallback(() => {
(value: string) => { setScriptContent(originalContent);
setScriptContent(value); }, [originalContent]);
setShowSaved(false);
// Clear existing timeouts // Delete script
if (saveTimeoutRef.current) { const handleDelete = useCallback(async () => {
clearTimeout(saveTimeoutRef.current); if (!currentProject?.path) return;
}
if (savedTimeoutRef.current) { setIsDeleting(true);
clearTimeout(savedTimeoutRef.current); try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.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);
}
}, [currentProject?.path]);
// Debounce save // Handle content change (no auto-save)
saveTimeoutRef.current = setTimeout(() => { const handleContentChange = useCallback((value: string) => {
saveScript(value); setScriptContent(value);
}, 1000);
},
[saveScript]
);
// Cleanup timeouts
useEffect(() => {
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current);
};
}, []); }, []);
return ( return (
@@ -177,24 +191,10 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<Terminal className="w-4 h-4 text-brand-500" /> <Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label> <Label className="text-foreground font-medium">Initialization Script</Label>
</div> </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> </div>
<p className="text-xs text-muted-foreground/80 leading-relaxed"> <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 Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
Bash on Windows for cross-platform compatibility. on Windows for cross-platform compatibility.
</p> </p>
{currentProject ? ( {currentProject ? (
@@ -203,6 +203,9 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<div className="flex items-center gap-2 text-xs text-muted-foreground/60"> <div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" /> <FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code> <code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -210,10 +213,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<ShellSyntaxEditor <>
value={scriptContent} <ShellSyntaxEditor
onChange={handleContentChange} value={scriptContent}
placeholder={`# Example initialization commands onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install npm install
# Or use pnpm # Or use pnpm
@@ -221,9 +225,52 @@ npm install
# Copy environment file # Copy environment file
# cp .env.example .env`} # cp .env.example .env`}
minHeight="200px" minHeight="200px"
data-testid="init-script-editor" maxHeight="500px"
/> data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)} )}
</> </>
) : ( ) : (