Files
automaker/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx

426 lines
17 KiB
TypeScript

import { useCallback } from 'react';
import {
Folder,
ChevronDown,
MoreVertical,
Palette,
Monitor,
Moon,
Sun,
Undo2,
Redo2,
RotateCcw,
Trash2,
Search,
LogOut,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import { initializeProject } from '@/lib/project-init';
import type { Project } from '@/lib/electron';
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, THEME_SUBMENU_CONSTANTS } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
/**
* Props for the ProjectSelectorWithOptions component.
* Defines the interface for the project selector dropdown with additional options menu.
*/
interface ProjectSelectorWithOptionsProps {
/** Whether the sidebar is currently expanded */
sidebarOpen: boolean;
/** Whether the project picker dropdown is currently open */
isProjectPickerOpen: boolean;
/** Callback to control the project picker dropdown open state */
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void;
/** Callback to show the remove from automaker confirmation dialog */
setShowRemoveFromAutomakerDialog: (show: boolean) => void;
}
/**
* A project selector component with search, drag-and-drop reordering, and options menu.
*
* Features:
* - Searchable dropdown for quick project switching
* - Drag-and-drop reordering of projects
* - Project-specific theme selection with live preview
* - Project history navigation (previous/next)
* - Option to move project to trash
*
* The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS
* for consistent submenu behavior across the application.
*
* @param props - Component props
* @returns The rendered project selector or null if sidebar is closed or no projects exist
*/
export function ProjectSelectorWithOptions({
sidebarOpen,
isProjectPickerOpen,
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
setShowRemoveFromAutomakerDialog,
}: ProjectSelectorWithOptionsProps) {
const {
projects,
currentProject,
projectHistory,
setCurrentProject,
reorderProjects,
cyclePrevProject,
cycleNextProject,
clearProjectHistory,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching
const setCurrentProjectWithInit = useCallback(
async (p: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(p.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(p);
},
[setCurrentProject]
);
const {
projectSearchQuery,
setProjectSearchQuery,
selectedProjectIndex,
projectSearchInputRef,
scrollContainerRef,
filteredProjects,
} = useProjectPicker({
projects,
currentProject,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject: setCurrentProjectWithInit,
});
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
const handleSelectProject = useCallback(
async (p: Project) => {
await setCurrentProjectWithInit(p);
setIsProjectPickerOpen(false);
},
[setCurrentProjectWithInit, setIsProjectPickerOpen]
);
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',
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 sm: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 */}
<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-8 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-1 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
ref={scrollContainerRef}
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
>
{filteredProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={handleSelectProject}
/>
))}
</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"></span> navigate{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60"></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 */}
{currentProject && (
<DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild>
<button
className={cn(
'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'
)}
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"
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
>
<DropdownMenuRadioGroup
value={currentProject.theme || ''}
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? 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 */}
{/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
{/* 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 */}
{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>
</>
)}
{/* Remove / Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowRemoveFromAutomakerDialog(true)}
className="text-muted-foreground focus:text-foreground"
data-testid="remove-from-automaker"
>
<LogOut className="w-4 h-4 mr-2" />
<span>Remove from Automaker</span>
</DropdownMenuItem>
<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>
);
}