"use client"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store"; import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; import { IS_MARKETING } from "@/config/app-config"; import { FolderOpen, Plus, Settings, FileText, LayoutGrid, Bot, Folder, X, PanelLeft, PanelLeftClose, ChevronDown, Redo2, Check, BookOpen, GripVertical, RotateCcw, Trash2, Undo2, UserCircle, MoreVertical, Palette, Monitor, Search, Bug, Activity, Recycle, Sparkles, Loader2, Terminal, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; import { getElectronAPI, Project, TrashedProject, RunningAgent, } from "@/lib/electron"; import { initializeProject, hasAppSpec, hasAutomakerDir, } from "@/lib/project-init"; import { toast } from "sonner"; import { themeOptions } from "@/config/theme-options"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors, closestCenter, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; interface NavSection { label?: string; items: NavItem[]; } interface NavItem { id: string; label: string; icon: any; shortcut?: string; } // Sortable Project Item Component interface SortableProjectItemProps { project: Project; currentProjectId: string | undefined; isHighlighted: boolean; onSelect: (project: Project) => void; } function SortableProjectItem({ project, currentProjectId, isHighlighted, onSelect, }: SortableProjectItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: project.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{/* Drag Handle */} {/* Project content - clickable area */}
onSelect(project)} > {project.name} {currentProjectId === project.id && ( )}
); } // Theme options for project theme selector - derived from the shared config const PROJECT_THEME_OPTIONS = [ { value: "", label: "Use Global", icon: Monitor }, ...themeOptions.map((opt) => ({ value: opt.value, label: opt.label, icon: opt.Icon, })), ] as const; export function Sidebar() { const { projects, trashedProjects, currentProject, currentView, sidebarOpen, projectHistory, upsertAndSetCurrentProject, setCurrentProject, setCurrentView, toggleSidebar, restoreTrashedProject, deleteTrashedProject, emptyTrash, reorderProjects, cyclePrevProject, cycleNextProject, clearProjectHistory, setProjectTheme, setTheme, setPreviewTheme, theme: globalTheme, moveProjectToTrash, } = useAppStore(); // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); // State for project picker dropdown const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); const [projectSearchQuery, setProjectSearchQuery] = useState(""); const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); const [showTrashDialog, setShowTrashDialog] = useState(false); const [activeTrashId, setActiveTrashId] = useState(null); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); // State for running agents count const [runningAgentsCount, setRunningAgentsCount] = useState(0); // State for new project setup dialog const [showSetupDialog, setShowSetupDialog] = useState(false); const [setupProjectPath, setSetupProjectPath] = useState(""); const [projectOverview, setProjectOverview] = useState(""); const [isCreatingSpec, setIsCreatingSpec] = useState(false); const [creatingSpecProjectPath, setCreatingSpecProjectPath] = useState< string | null >(null); const [generateFeatures, setGenerateFeatures] = useState(true); const [showSpecIndicator, setShowSpecIndicator] = useState(true); // Ref for project search input const projectSearchInputRef = useRef(null); // Filtered projects based on search query const filteredProjects = useMemo(() => { if (!projectSearchQuery.trim()) { return projects; } const query = projectSearchQuery.toLowerCase(); return projects.filter((project) => project.name.toLowerCase().includes(query) ); }, [projects, projectSearchQuery]); // Reset selection when filtered results change useEffect(() => { setSelectedProjectIndex(0); }, [filteredProjects.length, projectSearchQuery]); // Reset search query when dropdown closes useEffect(() => { if (!isProjectPickerOpen) { setProjectSearchQuery(""); setSelectedProjectIndex(0); } }, [isProjectPickerOpen]); // Focus the search input when dropdown opens useEffect(() => { if (isProjectPickerOpen) { // Small delay to ensure the dropdown is rendered setTimeout(() => { projectSearchInputRef.current?.focus(); }, 0); } }, [isProjectPickerOpen]); // Sensors for drag-and-drop const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5, // Small distance to start drag }, }) ); // Handle drag end for reordering projects const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = projects.findIndex((p) => p.id === active.id); const newIndex = projects.findIndex((p) => p.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { reorderProjects(oldIndex, newIndex); } } }, [projects, reorderProjects] ); // Subscribe to spec regeneration events useEffect(() => { const api = getElectronAPI(); if (!api.specRegeneration) return; const unsubscribe = api.specRegeneration.onEvent( (event: SpecRegenerationEvent) => { console.log("[Sidebar] Spec regeneration event:", event.type); if (event.type === "spec_regeneration_complete") { setIsCreatingSpec(false); setCreatingSpecProjectPath(null); setShowSetupDialog(false); setProjectOverview(""); setSetupProjectPath(""); toast.success("App specification created", { description: "Your project is now set up and ready to go!", }); // Navigate to spec view to show the new spec setCurrentView("spec"); } else if (event.type === "spec_regeneration_error") { setIsCreatingSpec(false); setCreatingSpecProjectPath(null); toast.error("Failed to create specification", { description: event.error, }); } } ); return () => { unsubscribe(); }; }, [setCurrentView]); // Fetch running agents count function - used for initial load and event-driven updates const fetchRunningAgentsCount = useCallback(async () => { try { const api = getElectronAPI(); if (api.runningAgents) { const result = await api.runningAgents.getAll(); if (result.success && result.runningAgents) { setRunningAgentsCount(result.runningAgents.length); } } } catch (error) { console.error("[Sidebar] Error fetching running agents count:", error); } }, []); // Subscribe to auto-mode events to update running agents count in real-time useEffect(() => { const api = getElectronAPI(); if (!api.autoMode) { // If autoMode is not available, still fetch initial count fetchRunningAgentsCount(); return; } // Initial fetch on mount fetchRunningAgentsCount(); const unsubscribe = api.autoMode.onEvent((event) => { // When a feature starts, completes, or errors, refresh the count if ( event.type === "auto_mode_feature_complete" || event.type === "auto_mode_error" || event.type === "auto_mode_feature_start" ) { fetchRunningAgentsCount(); } }); return () => { unsubscribe(); }; }, [fetchRunningAgentsCount]); // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { if (!setupProjectPath || !projectOverview.trim()) return; setIsCreatingSpec(true); setCreatingSpecProjectPath(setupProjectPath); setShowSpecIndicator(true); setShowSetupDialog(false); try { const api = getElectronAPI(); if (!api.specRegeneration) { toast.error("Spec regeneration not available"); setIsCreatingSpec(false); setCreatingSpecProjectPath(null); return; } const result = await api.specRegeneration.create( setupProjectPath, projectOverview.trim(), generateFeatures ); if (!result.success) { console.error("[Sidebar] Failed to start spec creation:", result.error); setIsCreatingSpec(false); setCreatingSpecProjectPath(null); toast.error("Failed to create specification", { description: result.error, }); } // If successful, we'll wait for the events to update the state } catch (error) { console.error("[Sidebar] Failed to create spec:", error); setIsCreatingSpec(false); setCreatingSpecProjectPath(null); toast.error("Failed to create specification", { description: error instanceof Error ? error.message : "Unknown error", }); } }, [setupProjectPath, projectOverview]); // Handle skipping setup const handleSkipSetup = useCallback(() => { setShowSetupDialog(false); setProjectOverview(""); setSetupProjectPath(""); toast.info("Setup skipped", { description: "You can set up your app_spec.txt later from the Spec view.", }); }, []); /** * Opens the system folder selection dialog and initializes the selected project. * Used by both the 'O' keyboard shortcut and the folder icon button. */ const handleOpenFolder = useCallback(async () => { const api = getElectronAPI(); const result = await api.openDirectory(); if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; try { // Check if this is a brand new project (no .automaker directory) const hadAutomakerDir = await hasAutomakerDir(path); // Initialize the .automaker directory structure const initResult = await initializeProject(path); if (!initResult.success) { toast.error("Failed to initialize project", { description: initResult.error || "Unknown error occurred", }); return; } // Upsert project and set as current (handles both create and update cases) // Theme preservation is handled by the store action const trashedProject = trashedProjects.find((p) => p.path === path); const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; const project = upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); if (!hadAutomakerDir && !specExists) { // This is a brand new project - show setup dialog setSetupProjectPath(path); setShowSetupDialog(true); toast.success("Project opened", { description: `Opened ${name}. Let's set up your app specification!`, }); } else if ( initResult.createdFiles && initResult.createdFiles.length > 0 ) { toast.success( initResult.isNewProject ? "Project initialized" : "Project updated", { description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, } ); } else { toast.success("Project opened", { description: `Opened ${name}`, }); } } catch (error) { console.error("[Sidebar] Failed to open project:", error); toast.error("Failed to open project", { description: error instanceof Error ? error.message : "Unknown error", }); } } }, [ trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, ]); const handleRestoreProject = useCallback( (projectId: string) => { restoreTrashedProject(projectId); toast.success("Project restored", { description: "Added back to your project list.", }); setShowTrashDialog(false); }, [restoreTrashedProject] ); const handleDeleteProjectFromDisk = useCallback( async (trashedProject: TrashedProject) => { const confirmed = window.confirm( `Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.` ); if (!confirmed) return; setActiveTrashId(trashedProject.id); try { const api = getElectronAPI(); if (!api.trashItem) { throw new Error("System Trash is not available in this build."); } const result = await api.trashItem(trashedProject.path); if (!result.success) { throw new Error(result.error || "Failed to delete project folder"); } deleteTrashedProject(trashedProject.id); toast.success("Project folder sent to system Trash", { description: trashedProject.path, }); } catch (error) { console.error("[Sidebar] Failed to delete project from disk:", error); toast.error("Failed to delete project folder", { description: error instanceof Error ? error.message : "Unknown error", }); } finally { setActiveTrashId(null); } }, [deleteTrashedProject] ); const handleEmptyTrash = useCallback(() => { if (trashedProjects.length === 0) { setShowTrashDialog(false); return; } const confirmed = window.confirm( "Clear all projects from recycle bin? This does not delete folders from disk." ); if (!confirmed) return; setIsEmptyingTrash(true); try { emptyTrash(); toast.success("Recycle bin cleared"); setShowTrashDialog(false); } finally { setIsEmptyingTrash(false); } }, [emptyTrash, trashedProjects.length]); const navSections: NavSection[] = [ { label: "Project", items: [ { id: "board", label: "Kanban Board", icon: LayoutGrid, shortcut: shortcuts.board, }, { id: "agent", label: "Agent Runner", icon: Bot, shortcut: shortcuts.agent, }, ], }, { label: "Tools", items: [ { id: "spec", label: "Spec Editor", icon: FileText, shortcut: shortcuts.spec, }, { id: "context", label: "Context", icon: BookOpen, shortcut: shortcuts.context, }, { id: "profiles", label: "AI Profiles", icon: UserCircle, shortcut: shortcuts.profiles, }, { id: "terminal", label: "Terminal", icon: Terminal, shortcut: shortcuts.terminal, }, ], }, ]; // Handle selecting the currently highlighted project const selectHighlightedProject = useCallback(() => { if ( filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length ) { setCurrentProject(filteredProjects[selectedProjectIndex]); setIsProjectPickerOpen(false); } }, [filteredProjects, selectedProjectIndex, setCurrentProject]); // Handle keyboard events when project picker is open useEffect(() => { if (!isProjectPickerOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsProjectPickerOpen(false); } else if (event.key === "Enter") { event.preventDefault(); selectHighlightedProject(); } else if (event.key === "ArrowDown") { event.preventDefault(); setSelectedProjectIndex((prev) => prev < filteredProjects.length - 1 ? prev + 1 : prev ); } else if (event.key === "ArrowUp") { event.preventDefault(); setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); } else if ( event.key.toLowerCase() === "p" && !event.metaKey && !event.ctrlKey ) { // Toggle off when P is pressed (not with modifiers) while dropdown is open // Only if not typing in the search input if (document.activeElement !== projectSearchInputRef.current) { event.preventDefault(); setIsProjectPickerOpen(false); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]); // Build keyboard shortcuts for navigation const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcutsList: KeyboardShortcut[] = []; // Sidebar toggle shortcut - always available shortcutsList.push({ key: shortcuts.toggleSidebar, action: () => toggleSidebar(), description: "Toggle sidebar", }); // Open project shortcut - opens the folder selection dialog directly shortcutsList.push({ key: shortcuts.openProject, action: () => handleOpenFolder(), description: "Open folder selection dialog", }); // Project picker shortcut - only when we have projects if (projects.length > 0) { shortcutsList.push({ key: shortcuts.projectPicker, action: () => setIsProjectPickerOpen((prev) => !prev), description: "Toggle project picker", }); } // Project cycling shortcuts - only when we have project history if (projectHistory.length > 1) { shortcutsList.push({ key: shortcuts.cyclePrevProject, action: () => cyclePrevProject(), description: "Cycle to previous project (MRU)", }); shortcutsList.push({ key: shortcuts.cycleNextProject, action: () => cycleNextProject(), description: "Cycle to next project (LRU)", }); } // Only enable nav shortcuts if there's a current project if (currentProject) { navSections.forEach((section) => { section.items.forEach((item) => { if (item.shortcut) { shortcutsList.push({ key: item.shortcut, action: () => setCurrentView(item.id as any), description: `Navigate to ${item.label}`, }); } }); }); // Add settings shortcut shortcutsList.push({ key: shortcuts.settings, action: () => setCurrentView("settings"), description: "Navigate to Settings", }); } return shortcutsList; }, [ shortcuts, currentProject, setCurrentView, toggleSidebar, projects.length, handleOpenFolder, projectHistory.length, cyclePrevProject, cycleNextProject, navSections, ]); // Register keyboard shortcuts useKeyboardShortcuts(navigationShortcuts); const isActiveRoute = (id: string) => { return currentView === id; }; return (