♻️ refactor: streamline sidebar component structure and enhance functionality

- Extracted new components: ProjectSelectorWithOptions, SidebarFooter, TrashDialog, and OnboardingDialog to improve code organization and reusability.
- Introduced new hooks: useProjectCreation, useSetupDialog, and useTrashDialog for better state management and modularity.
- Updated sidebar.tsx to utilize the new components and hooks, reducing complexity and improving maintainability.
- Enhanced project creation and setup processes with dedicated dialogs and streamlined user interactions.

This refactor aims to enhance the user experience and maintainability of the sidebar by modularizing functionality and improving the overall structure.
This commit is contained in:
Kacper
2025-12-21 21:23:04 +01:00
parent aafd0b3991
commit a40bb6df24
12 changed files with 1371 additions and 1160 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,3 +6,5 @@ export { AutomakerLogo } from './automaker-logo';
export { SidebarHeader } from './sidebar-header';
export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer';

View File

@@ -0,0 +1,374 @@
import {
Folder,
ChevronDown,
MoreVertical,
Palette,
Monitor,
Moon,
Sun,
Undo2,
Redo2,
RotateCcw,
Trash2,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
interface ProjectSelectorWithOptionsProps {
sidebarOpen: boolean;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean) => void;
setShowDeleteProjectDialog: (show: boolean) => void;
}
export function ProjectSelectorWithOptions({
sidebarOpen,
isProjectPickerOpen,
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
}: ProjectSelectorWithOptionsProps) {
// Get data from store
const {
projects,
currentProject,
projectHistory,
setCurrentProject,
reorderProjects,
cyclePrevProject,
cycleNextProject,
clearProjectHistory,
} = useAppStore();
// Get keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
const {
projectSearchQuery,
setProjectSearchQuery,
selectedProjectIndex,
projectSearchInputRef,
filteredProjects,
} = useProjectPicker({
projects,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
});
// Drag-and-drop handlers
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
// Theme management
const {
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
if (!sidebarOpen || projects.length === 0) {
return null;
}
return (
<div className="px-3 mt-3 flex items-center gap-2.5">
<DropdownMenu open={isProjectPickerOpen} onOpenChange={setIsProjectPickerOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex-1 flex items-center justify-between px-3.5 py-3 rounded-xl',
// Premium glass background
'bg-gradient-to-br from-accent/40 to-accent/20',
'hover:from-accent/50 hover:to-accent/30',
'border border-border/50 hover:border-border/70',
// Subtle inner shadow
'shadow-sm shadow-black/5',
'text-foreground titlebar-no-drag min-w-0',
'transition-all duration-200 ease-out',
'hover:scale-[1.01] active:scale-[0.99]',
isProjectPickerOpen &&
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
)}
data-testid="project-selector"
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<Folder className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium truncate">
{currentProject?.name || 'Select Project'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
data-testid="project-picker-shortcut"
>
{formatShortcut(shortcuts.projectPicker, true)}
</span>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200',
isProjectPickerOpen && 'rotate-180'
)}
/>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-72 bg-popover/95 backdrop-blur-xl border-border shadow-xl p-1.5"
align="start"
data-testid="project-picker-dropdown"
>
{/* Search input for type-ahead filtering */}
<div className="px-1 pb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={projectSearchInputRef}
type="text"
placeholder="Search projects..."
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className={cn(
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
'border border-border bg-background/50',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
'transition-all duration-200'
)}
data-testid="project-search-input"
/>
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No projects found
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5 max-h-64 overflow-y-auto">
{filteredProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Keyboard hint */}
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
<span className="text-foreground/60">arrow</span> navigate{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">enter</span> select{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">esc</span> close
</p>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Project Options Menu - theme and history */}
{currentProject && (
<DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild>
<button
className={cn(
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',
'transition-all duration-200 ease-out titlebar-no-drag',
'hover:scale-[1.02] active:scale-[0.98]'
)}
title="Project options"
data-testid="project-options-menu"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-popover/95 backdrop-blur-xl">
{/* Project Theme Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="project-theme-trigger">
<Palette className="w-4 h-4 mr-2" />
<span className="flex-1">Project Theme</span>
{currentProject.theme && (
<span className="text-[10px] text-muted-foreground ml-2 capitalize">
{currentProject.theme}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-[420px] bg-popover/95 backdrop-blur-xl"
data-testid="project-theme-menu"
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
>
{/* Use Global Option */}
<DropdownMenuRadioGroup
value={currentProject.theme || ''}
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
setProjectTheme(
currentProject.id,
value === '' ? null : (value as ThemeMode)
);
}
}}
>
<div
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={() => setPreviewTheme(null)}
>
<DropdownMenuRadioItem
value=""
data-testid="project-theme-global"
className="mx-2"
>
<Monitor className="w-4 h-4 mr-2" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</DropdownMenuRadioItem>
</div>
<DropdownMenuSeparator />
{/* Two Column Layout */}
<div className="flex gap-2 p-2">
{/* Dark Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Moon className="w-3 h-3" />
Dark
</div>
<div className="space-y-0.5">
{PROJECT_DARK_THEMES.map((option) => (
<ThemeMenuItem
key={option.value}
option={option}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
/>
))}
</div>
</div>
{/* Light Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Sun className="w-3 h-3" />
Light
</div>
<div className="space-y-0.5">
{PROJECT_LIGHT_THEMES.map((option) => (
<ThemeMenuItem
key={option.value}
option={option}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
/>
))}
</div>
</div>
</div>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Project History Section - only show when there's history */}
{projectHistory.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Project History
</DropdownMenuLabel>
<DropdownMenuItem onClick={cyclePrevProject} data-testid="cycle-prev-project">
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{formatShortcut(shortcuts.cyclePrevProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{formatShortcut(shortcuts.cycleNextProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
<RotateCcw className="w-4 h-4 mr-2" />
<span>Clear history</span>
</DropdownMenuItem>
</>
)}
{/* Move to Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
data-testid="move-project-to-trash"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Move to Trash</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,269 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { BookOpen, Activity, Settings } from 'lucide-react';
interface SidebarFooterProps {
sidebarOpen: boolean;
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
hideWiki: boolean;
hideRunningAgents: boolean;
runningAgentsCount: number;
shortcuts: {
settings: string;
};
}
export function SidebarFooter({
sidebarOpen,
isActiveRoute,
navigate,
hideWiki,
hideRunningAgents,
runningAgentsCount,
shortcuts,
}: SidebarFooterProps) {
return (
<div
className={cn(
'shrink-0',
// Top border with gradient fade
'border-t border-border/40',
// Elevated background for visual separation
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/wiki' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('wiki')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Wiki' : undefined}
data-testid="wiki-link"
>
<BookOpen
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('wiki')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Wiki
</span>
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Wiki
</span>
)}
</button>
</div>
)}
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Running Agents' : undefined}
data-testid="running-agents-link"
>
<div className="relative">
<Activity
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('running-agents')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</div>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
isActiveRoute('running-agents') && 'bg-brand-600'
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
{runningAgentsCount}
</span>
)}
</span>
)}
</button>
</div>
)}
{/* Settings Link */}
<div className="p-2">
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Settings' : undefined}
data-testid="settings-button"
>
<Settings
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('settings')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Settings
</span>
{sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid="shortcut-settings"
>
{formatShortcut(shortcuts.settings, true)}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</span>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TrashDialog } from './trash-dialog';
export { OnboardingDialog } from './onboarding-dialog';

View File

@@ -0,0 +1,122 @@
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface OnboardingDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
newProjectName: string;
onSkip: () => void;
onGenerateSpec: () => void;
}
export function OnboardingDialog({
open,
onOpenChange,
newProjectName,
onSkip,
onGenerateSpec,
}: OnboardingDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onSkip();
}
onOpenChange(isOpen);
}}
>
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<Rocket className="w-6 h-6 text-brand-500" />
</div>
<div>
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-6">
{/* Main explanation */}
<div className="space-y-3">
<p className="text-sm text-foreground leading-relaxed">
Would you like to auto-generate your <strong>app_spec.txt</strong>? This file helps
describe your project and is used to pre-populate your backlog with features to work
on.
</p>
</div>
{/* Benefits list */}
<div className="space-y-3 rounded-xl bg-muted/30 border border-border/50 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Pre-populate your backlog</p>
<p className="text-xs text-muted-foreground mt-1">
Automatically generate features based on your project specification
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Better AI assistance</p>
<p className="text-xs text-muted-foreground mt-1">
Help AI agents understand your project structure and tech stack
</p>
</div>
</div>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Project documentation</p>
<p className="text-xs text-muted-foreground mt-1">
Keep a clear record of your project&apos;s capabilities and features
</p>
</div>
</div>
</div>
{/* Info box */}
<div className="rounded-xl bg-brand-500/5 border border-brand-500/10 p-3">
<p className="text-xs text-muted-foreground leading-relaxed">
<strong className="text-foreground">Tip:</strong> You can always generate or edit your
app_spec.txt later from the Spec Editor in the sidebar.
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground"
>
Skip for now
</Button>
<Button
onClick={onGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />
Generate App Spec
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,116 @@
import { X, Trash2, Undo2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { TrashedProject } from '@/lib/electron';
interface TrashDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
trashedProjects: TrashedProject[];
activeTrashId: string | null;
handleRestoreProject: (id: string) => void;
handleDeleteProjectFromDisk: (project: TrashedProject) => void;
deleteTrashedProject: (id: string) => void;
handleEmptyTrash: () => void;
isEmptyingTrash: boolean;
}
export function TrashDialog({
open,
onOpenChange,
trashedProjects,
activeTrashId,
handleRestoreProject,
handleDeleteProjectFromDisk,
deleteTrashedProject,
handleEmptyTrash,
isEmptyingTrash,
}: TrashDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your system Trash.
</DialogDescription>
</DialogHeader>
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
<div
key={project.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={handleEmptyTrash}
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,3 +6,7 @@ export { useTrashOperations } from './use-trash-operations';
export { useProjectPicker } from './use-project-picker';
export { useSpecRegeneration } from './use-spec-regeneration';
export { useNavigation } from './use-navigation';
export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme';

View File

@@ -0,0 +1,175 @@
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
import type { ThemeMode } from '@/store/app-store';
import type { TrashedProject, Project } from '@/lib/electron';
interface UseProjectCreationProps {
trashedProjects: TrashedProject[];
currentProject: Project | null;
globalTheme: ThemeMode;
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
}
export function useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
// Modal state
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
// Onboarding state
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectPath, setNewProjectPath] = useState('');
/**
* Common logic for all project creation flows
*/
const finalizeProjectCreation = useCallback(
async (projectPath: string, projectName: string) => {
try {
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write initial app_spec.txt with basic XML structure
const api = getElectronAPI();
await api.fs.writeFile(
`${projectPath}/app_spec.txt`,
`<?xml version="1.0" encoding="UTF-8"?>\n<project>\n <name>${projectName}</name>\n <description>Add your project description here</description>\n</project>`
);
// Determine theme: try trashed project theme, then current project theme, then global
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success('Project created successfully');
} catch (error) {
console.error('[ProjectCreation] Failed to finalize project:', error);
toast.error('Failed to initialize project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a blank project with .automaker structure
*/
const handleCreateBlankProject = useCallback(
async (projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.fs.createFolder(projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
} catch (error) {
console.error('[ProjectCreation] Failed to create blank project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[finalizeProjectCreation]
);
/**
* Create project from a starter template
*/
const handleCreateFromTemplate = useCallback(
async (template: StarterTemplate, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone template repository
await api.git.clone(template.githubUrl, projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
} catch (error) {
console.error('[ProjectCreation] Failed to create from template:', error);
toast.error('Failed to create project from template', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[finalizeProjectCreation]
);
/**
* Create project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = useCallback(
async (repoUrl: string, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone custom repository
await api.git.clone(repoUrl, projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
} catch (error) {
console.error('[ProjectCreation] Failed to create from custom URL:', error);
toast.error('Failed to create project from URL', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[finalizeProjectCreation]
);
return {
// Modal state
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
// Onboarding state
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
// Handlers
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
};
}

View File

@@ -0,0 +1,25 @@
import { useAppStore } from '@/store/app-store';
import { useThemePreview } from './use-theme-preview';
/**
* Hook that manages project theme state and preview handlers
*/
export function useProjectTheme() {
// Get theme-related values from store
const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore();
// Get debounced preview handlers
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
return {
// Theme state
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
// Preview handlers
handlePreviewEnter,
handlePreviewLeave,
};
}

View File

@@ -0,0 +1,147 @@
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { FeatureCount } from '@/components/views/spec-view/types';
interface UseSetupDialogProps {
setSpecCreatingForProject: (path: string | null) => void;
newProjectPath: string;
setNewProjectName: (name: string) => void;
setNewProjectPath: (path: string) => void;
setShowOnboardingDialog: (show: boolean) => void;
}
export function useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
}: UseSetupDialogProps) {
// Setup dialog state
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState('');
const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
/**
* Handle creating initial spec for new project
*/
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
// Set store state immediately so the loader shows up right away
setSpecCreatingForProject(setupProjectPath);
setShowSetupDialog(false);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
toast.error('Spec regeneration not available');
setSpecCreatingForProject(null);
return;
}
const result = await api.specRegeneration.create(
setupProjectPath,
projectOverview.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
if (!result.success) {
console.error('[SetupDialog] Failed to start spec creation:', result.error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: result.error,
});
} else {
// Show processing toast to inform user
toast.info('Generating app specification...', {
description: "This may take a minute. You'll be notified when complete.",
});
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error('[SetupDialog] Failed to create spec:', error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
/**
* Handle skipping setup
*/
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setProjectOverview('');
setSetupProjectPath('');
// Clear onboarding state if we came from onboarding
if (newProjectPath) {
setNewProjectName('');
setNewProjectPath('');
}
toast.info('Setup skipped', {
description: 'You can set up your app_spec.txt later from the Spec view.',
});
}, [newProjectPath, setNewProjectName, setNewProjectPath]);
/**
* Handle onboarding dialog - generate spec
*/
const handleOnboardingGenerateSpec = useCallback(() => {
setShowOnboardingDialog(false);
// Navigate to the setup dialog flow
setSetupProjectPath(newProjectPath);
setProjectOverview('');
setShowSetupDialog(true);
}, [newProjectPath, setShowOnboardingDialog]);
/**
* Handle onboarding dialog - skip
*/
const handleOnboardingSkip = useCallback(() => {
setShowOnboardingDialog(false);
setNewProjectName('');
setNewProjectPath('');
toast.info('You can generate your app_spec.txt anytime from the Spec view', {
description: 'Your project is ready to use!',
});
}, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]);
return {
// State
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
// Handlers
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
};
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTrashOperations } from './use-trash-operations';
import type { TrashedProject } from '@/lib/electron';
interface UseTrashDialogProps {
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
/**
* Hook that combines trash operations with dialog state management
*/
export function useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashDialogProps) {
// Dialog state
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Reuse existing trash operations logic
const trashOperations = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
return {
// Dialog state
showTrashDialog,
setShowTrashDialog,
// Trash operations (spread from existing hook)
...trashOperations,
};
}