"use client";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { useAppStore, formatShortcut } from "@/store/app-store";
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
import {
FolderOpen,
Plus,
Settings,
FileText,
LayoutGrid,
Bot,
Folder,
X,
Wrench,
PanelLeft,
PanelLeftClose,
ChevronDown,
Redo2,
Check,
BookOpen,
GripVertical,
RotateCcw,
Trash2,
Undo2,
UserCircle,
MoreVertical,
Palette,
Moon,
Sun,
Terminal,
Ghost,
Snowflake,
Flame,
Sparkles as TokyoNightIcon,
Eclipse,
Trees,
Cat,
Atom,
Radio,
Monitor,
Search,
Bug,
Activity,
} 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 } from "@/lib/electron";
import {
initializeProject,
hasAppSpec,
hasAutomakerDir,
} from "@/lib/project-init";
import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
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
const PROJECT_THEME_OPTIONS = [
{ value: "", label: "Use Global", icon: Monitor },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "light", label: "Light", icon: Sun },
{ value: "retro", label: "Retro", icon: Terminal },
{ value: "dracula", label: "Dracula", icon: Ghost },
{ value: "nord", label: "Nord", icon: Snowflake },
{ value: "monokai", label: "Monokai", icon: Flame },
{ value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon },
{ value: "solarized", label: "Solarized", icon: Eclipse },
{ value: "gruvbox", label: "Gruvbox", icon: Trees },
{ value: "catppuccin", label: "Catppuccin", icon: Cat },
{ value: "onedark", label: "One Dark", icon: Atom },
{ value: "synthwave", label: "Synthwave", icon: Radio },
] as const;
export function Sidebar() {
const {
projects,
trashedProjects,
currentProject,
currentView,
sidebarOpen,
projectHistory,
addProject,
setCurrentProject,
setCurrentView,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
reorderProjects,
cyclePrevProject,
cycleNextProject,
clearProjectHistory,
setProjectTheme,
theme: globalTheme,
} = 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 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]);
// 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;
}
// Check if project already exists (by path) to preserve theme and other settings
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store (this will update the existing entry)
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
useAppStore.setState({ projects: updatedProjects });
} else {
// Create new project
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
addProject(project);
}
setCurrentProject(project);
// 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",
});
}
}
}, [projects, addProject, setCurrentProject]);
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 trashed projects from Automaker? This does not delete folders from disk."
);
if (!confirmed) return;
setIsEmptyingTrash(true);
try {
emptyTrash();
toast.success("Trash 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: "tools",
label: "Agent Tools",
icon: Wrench,
shortcut: shortcuts.tools,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
shortcut: shortcuts.profiles,
},
],
},
];
// 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 (
);
}