mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
426 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|