"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 {
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,
Rocket,
Zap,
CheckCircle2,
ArrowRight,
} 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 type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import {
ProjectSetupDialog,
type FeatureCount,
} from "@/components/layout/project-setup-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";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
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,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
// Note: Next.js requires static access to process.env variables (no dynamic keys)
const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
const hideRunningAgents =
process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
// 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 modal
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
// State for new project onboarding dialog
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
// State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [generateFeatures, setGenerateFeatures] = useState(true);
const [featureCount, setFeatureCount] = useState(50);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Ref for project search input
const projectSearchInputRef = useRef(null);
// Auto-collapse sidebar on small screens
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 1024px)"); // lg breakpoint
const handleResize = () => {
if (mediaQuery.matches && sidebarOpen) {
// Auto-collapse on small screens
toggleSidebar();
}
};
// Check on mount
handleResize();
// Listen for changes
mediaQuery.addEventListener("change", handleResize);
return () => mediaQuery.removeEventListener("change", handleResize);
}, [sidebarOpen, toggleSidebar]);
// 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,
"for project:",
event.projectPath
);
// Only handle events for the project we're currently setting up
if (
event.projectPath !== creatingSpecProjectPath &&
event.projectPath !== setupProjectPath
) {
console.log(
"[Sidebar] Ignoring event - not for project being set up"
);
return;
}
if (event.type === "spec_regeneration_complete") {
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
// Clear onboarding state if we came from onboarding
setNewProjectName("");
setNewProjectPath("");
toast.success("App specification created", {
description: "Your project is now set up and ready to go!",
});
} else if (event.type === "spec_regeneration_error") {
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: event.error,
});
}
}
);
return () => {
unsubscribe();
};
}, [
setCurrentView,
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
]);
// 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;
// Set store state immediately so the loader shows up right away
setSpecCreatingForProject(setupProjectPath);
setShowSpecIndicator(true);
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,
undefined, // analyzeProject - use default
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
if (!result.success) {
console.error("[Sidebar] 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("[Sidebar] Failed to create spec:", error);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [
setupProjectPath,
projectOverview,
generateFeatures,
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]);
// Handle onboarding dialog - generate spec
const handleOnboardingGenerateSpec = useCallback(() => {
setShowOnboardingDialog(false);
// Navigate to the setup dialog flow
setSetupProjectPath(newProjectPath);
setProjectOverview("");
setShowSetupDialog(true);
}, [newProjectPath]);
// 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!",
}
);
}, []);
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = useCallback(
async (projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
return;
}
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`
${projectName}
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created", {
description: `Created ${projectName} with .automaker directory`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a GitHub starter template
*/
const handleCreateFromTemplate = useCallback(
async (
template: StarterTemplate,
projectName: string,
parentDir: string
) => {
setIsCreatingProject(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the template repository
const cloneResult = await httpClient.templates.clone(
template.repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone template", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with template-specific info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`
${projectName}
This project was created from the "${template.name}" starter template.
${template.description}
${template.techStack
.map((tech) => `${tech}`)
.join("\n ")}
${template.features
.map((feature) => `${feature}`)
.join("\n ")}
`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from template", {
description: `Created ${projectName} from ${template.name}`,
});
} catch (error) {
console.error(
"[Sidebar] Failed to create project from template:",
error
);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = useCallback(
async (repoUrl: string, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the repository
const cloneResult = await httpClient.templates.clone(
repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone repository", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with basic info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`
${projectName}
This project was cloned from ${repoUrl}.
The AI agent will analyze the project structure.
`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from repository", {
description: `Created ${projectName} from ${repoUrl}`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project from URL:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* 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[] = useMemo(() => {
const allToolsItems: NavItem[] = [
{
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,
},
];
// Filter out hidden items
const visibleToolsItems = allToolsItems.filter((item) => {
if (item.id === "spec" && hideSpecEditor) {
return false;
}
if (item.id === "context" && hideContext) {
return false;
}
if (item.id === "profiles" && hideAiProfiles) {
return false;
}
if (item.id === "terminal" && hideTerminal) {
return false;
}
return true;
});
return [
{
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: visibleToolsItems,
},
];
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// 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 (
);
}