mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge the unified-sidebar implementation into the standard sidebar folder structure. The unified sidebar becomes the canonical sidebar with improved features including collapsible sections, scroll indicators, and enhanced mobile support. - Delete old sidebar.tsx - Move unified-sidebar components to sidebar/components - Rename UnifiedSidebar to Sidebar - Update all imports in __root.tsx - Remove redundant unified-sidebar folder
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
|
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useAppStore } from '@/store/app-store';
|
|
import { useNotificationsStore } from '@/store/notifications-store';
|
|
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|
import { getElectronAPI } from '@/lib/electron';
|
|
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
|
import { toast } from 'sonner';
|
|
import { useIsCompact } from '@/hooks/use-media-query';
|
|
import type { Project } from '@/lib/electron';
|
|
|
|
// Sidebar components
|
|
import {
|
|
SidebarNavigation,
|
|
CollapseToggleButton,
|
|
MobileSidebarToggle,
|
|
SidebarHeader,
|
|
SidebarFooter,
|
|
} from './components';
|
|
import { SIDEBAR_FEATURE_FLAGS } from './constants';
|
|
import {
|
|
useSidebarAutoCollapse,
|
|
useRunningAgents,
|
|
useSpecRegeneration,
|
|
useNavigation,
|
|
useProjectCreation,
|
|
useSetupDialog,
|
|
useTrashOperations,
|
|
useUnviewedValidations,
|
|
} from './hooks';
|
|
import { TrashDialog, OnboardingDialog } from './dialogs';
|
|
|
|
// Reuse dialogs from project-switcher
|
|
import { ProjectContextMenu } from '../project-switcher/components/project-context-menu';
|
|
import { EditProjectDialog } from '../project-switcher/components/edit-project-dialog';
|
|
|
|
// Import shared dialogs
|
|
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
|
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
|
|
|
const logger = createLogger('Sidebar');
|
|
|
|
export function Sidebar() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const {
|
|
projects,
|
|
trashedProjects,
|
|
currentProject,
|
|
sidebarOpen,
|
|
mobileSidebarHidden,
|
|
projectHistory,
|
|
upsertAndSetCurrentProject,
|
|
toggleSidebar,
|
|
toggleMobileSidebarHidden,
|
|
restoreTrashedProject,
|
|
deleteTrashedProject,
|
|
emptyTrash,
|
|
cyclePrevProject,
|
|
cycleNextProject,
|
|
moveProjectToTrash,
|
|
specCreatingForProject,
|
|
setSpecCreatingForProject,
|
|
setCurrentProject,
|
|
} = useAppStore();
|
|
|
|
const isCompact = useIsCompact();
|
|
|
|
// Environment variable flags for hiding sidebar items
|
|
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } =
|
|
SIDEBAR_FEATURE_FLAGS;
|
|
|
|
// Get customizable keyboard shortcuts
|
|
const shortcuts = useKeyboardShortcutsConfig();
|
|
|
|
// Get unread notifications count
|
|
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
|
|
|
// State for context menu
|
|
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
|
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
|
null
|
|
);
|
|
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
|
|
|
|
// State for delete project confirmation dialog
|
|
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
|
|
|
// State for trash dialog
|
|
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
|
|
|
// Project creation state and handlers
|
|
const {
|
|
showNewProjectModal,
|
|
setShowNewProjectModal,
|
|
isCreatingProject,
|
|
showOnboardingDialog,
|
|
setShowOnboardingDialog,
|
|
newProjectName,
|
|
setNewProjectName,
|
|
newProjectPath,
|
|
setNewProjectPath,
|
|
handleCreateBlankProject,
|
|
handleCreateFromTemplate,
|
|
handleCreateFromCustomUrl,
|
|
} = useProjectCreation({
|
|
upsertAndSetCurrentProject,
|
|
});
|
|
|
|
// Setup dialog state and handlers
|
|
const {
|
|
showSetupDialog,
|
|
setShowSetupDialog,
|
|
setupProjectPath,
|
|
setSetupProjectPath,
|
|
projectOverview,
|
|
setProjectOverview,
|
|
generateFeatures,
|
|
setGenerateFeatures,
|
|
analyzeProject,
|
|
setAnalyzeProject,
|
|
featureCount,
|
|
setFeatureCount,
|
|
handleCreateInitialSpec,
|
|
handleSkipSetup,
|
|
handleOnboardingGenerateSpec,
|
|
handleOnboardingSkip,
|
|
} = useSetupDialog({
|
|
setSpecCreatingForProject,
|
|
newProjectPath,
|
|
setNewProjectName,
|
|
setNewProjectPath,
|
|
setShowOnboardingDialog,
|
|
});
|
|
|
|
// Derive isCreatingSpec from store state
|
|
const isCreatingSpec = specCreatingForProject !== null;
|
|
const creatingSpecProjectPath = specCreatingForProject;
|
|
// Check if the current project is specifically the one generating spec
|
|
const isCurrentProjectGeneratingSpec =
|
|
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
|
|
|
// Auto-collapse sidebar on small screens
|
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
|
|
|
// Running agents count
|
|
const { runningAgentsCount } = useRunningAgents();
|
|
|
|
// Unviewed validations count
|
|
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
|
|
|
// Trash operations
|
|
const {
|
|
activeTrashId,
|
|
isEmptyingTrash,
|
|
handleRestoreProject,
|
|
handleDeleteProjectFromDisk,
|
|
handleEmptyTrash,
|
|
} = useTrashOperations({
|
|
restoreTrashedProject,
|
|
deleteTrashedProject,
|
|
emptyTrash,
|
|
});
|
|
|
|
// Spec regeneration events
|
|
useSpecRegeneration({
|
|
creatingSpecProjectPath,
|
|
setupProjectPath,
|
|
setSpecCreatingForProject,
|
|
setShowSetupDialog,
|
|
setProjectOverview,
|
|
setSetupProjectPath,
|
|
setNewProjectName,
|
|
setNewProjectPath,
|
|
});
|
|
|
|
// Context menu handlers
|
|
const handleContextMenu = useCallback((project: Project, event: React.MouseEvent) => {
|
|
event.preventDefault();
|
|
setContextMenuProject(project);
|
|
setContextMenuPosition({ x: event.clientX, y: event.clientY });
|
|
}, []);
|
|
|
|
const handleCloseContextMenu = useCallback(() => {
|
|
setContextMenuProject(null);
|
|
setContextMenuPosition(null);
|
|
}, []);
|
|
|
|
const handleEditProject = useCallback(
|
|
(project: Project) => {
|
|
setEditDialogProject(project);
|
|
handleCloseContextMenu();
|
|
},
|
|
[handleCloseContextMenu]
|
|
);
|
|
|
|
/**
|
|
* Opens the system folder selection dialog and initializes the selected project.
|
|
*/
|
|
const handleOpenFolder = useCallback(async () => {
|
|
const api = getElectronAPI();
|
|
const result = await api.openDirectory();
|
|
|
|
if (!result.canceled && result.filePaths[0]) {
|
|
const path = result.filePaths[0];
|
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
|
|
|
try {
|
|
const hadAutomakerDir = await hasAutomakerDir(path);
|
|
const initResult = await initializeProject(path);
|
|
|
|
if (!initResult.success) {
|
|
toast.error('Failed to initialize project', {
|
|
description: initResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
upsertAndSetCurrentProject(path, name);
|
|
const specExists = await hasAppSpec(path);
|
|
|
|
if (!hadAutomakerDir && !specExists) {
|
|
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}`,
|
|
});
|
|
}
|
|
|
|
navigate({ to: '/board' });
|
|
} catch (error) {
|
|
logger.error('Failed to open project:', error);
|
|
toast.error('Failed to open project', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
}, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]);
|
|
|
|
const handleNewProject = useCallback(() => {
|
|
setShowNewProjectModal(true);
|
|
}, [setShowNewProjectModal]);
|
|
|
|
// Navigation sections and keyboard shortcuts
|
|
const { navSections, navigationShortcuts } = useNavigation({
|
|
shortcuts,
|
|
hideSpecEditor,
|
|
hideContext,
|
|
hideTerminal,
|
|
currentProject,
|
|
projects,
|
|
projectHistory,
|
|
navigate,
|
|
toggleSidebar,
|
|
handleOpenFolder,
|
|
cyclePrevProject,
|
|
cycleNextProject,
|
|
unviewedValidationsCount,
|
|
unreadNotificationsCount,
|
|
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
|
});
|
|
|
|
// Register keyboard shortcuts
|
|
useKeyboardShortcuts(navigationShortcuts);
|
|
|
|
// Keyboard shortcuts for project switching (1-9, 0)
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
return;
|
|
}
|
|
|
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key;
|
|
let projectIndex: number | null = null;
|
|
|
|
if (key >= '1' && key <= '9') {
|
|
projectIndex = parseInt(key, 10) - 1;
|
|
} else if (key === '0') {
|
|
projectIndex = 9;
|
|
}
|
|
|
|
if (projectIndex !== null && projectIndex < projects.length) {
|
|
const targetProject = projects[projectIndex];
|
|
if (targetProject && targetProject.id !== currentProject?.id) {
|
|
setCurrentProject(targetProject);
|
|
navigate({ to: '/board' });
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [projects, currentProject, setCurrentProject, navigate]);
|
|
|
|
const isActiveRoute = (id: string) => {
|
|
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
|
return location.pathname === routePath;
|
|
};
|
|
|
|
// Track if nav can scroll down
|
|
const [canScrollDown, setCanScrollDown] = useState(false);
|
|
|
|
// Check if sidebar should be completely hidden on mobile
|
|
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
|
|
|
return (
|
|
<>
|
|
{/* Floating toggle to show sidebar on mobile when hidden */}
|
|
<MobileSidebarToggle />
|
|
|
|
{/* Mobile backdrop overlay */}
|
|
{sidebarOpen && !shouldHideSidebar && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
|
onClick={toggleSidebar}
|
|
data-testid="sidebar-backdrop"
|
|
/>
|
|
)}
|
|
|
|
<aside
|
|
className={cn(
|
|
'flex-shrink-0 flex flex-col z-30',
|
|
// Glass morphism background with gradient
|
|
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
|
// Premium border with subtle glow
|
|
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
|
// Smooth width transition
|
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
|
// Mobile: completely hidden when mobileSidebarHidden is true
|
|
shouldHideSidebar && 'hidden',
|
|
// Width based on state
|
|
!shouldHideSidebar &&
|
|
(sidebarOpen
|
|
? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]'
|
|
: 'relative w-14')
|
|
)}
|
|
data-testid="sidebar"
|
|
>
|
|
<CollapseToggleButton
|
|
sidebarOpen={sidebarOpen}
|
|
toggleSidebar={toggleSidebar}
|
|
shortcut={shortcuts.toggleSidebar}
|
|
/>
|
|
|
|
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
|
|
{!sidebarOpen && isCompact && (
|
|
<button
|
|
onClick={toggleMobileSidebarHidden}
|
|
className={cn(
|
|
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
|
|
'flex items-center justify-center w-6 h-10 rounded-r-lg',
|
|
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
|
|
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
|
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
|
|
'transition-all duration-200',
|
|
'hover:w-8 active:scale-95'
|
|
)}
|
|
aria-label="Hide sidebar"
|
|
data-testid="sidebar-mobile-hide"
|
|
>
|
|
<PanelLeftClose className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
<SidebarHeader
|
|
sidebarOpen={sidebarOpen}
|
|
currentProject={currentProject}
|
|
onNewProject={handleNewProject}
|
|
onOpenFolder={handleOpenFolder}
|
|
onProjectContextMenu={handleContextMenu}
|
|
/>
|
|
|
|
<SidebarNavigation
|
|
currentProject={currentProject}
|
|
sidebarOpen={sidebarOpen}
|
|
navSections={navSections}
|
|
isActiveRoute={isActiveRoute}
|
|
navigate={navigate}
|
|
onScrollStateChange={setCanScrollDown}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scroll indicator - shows there's more content below */}
|
|
{canScrollDown && sidebarOpen && (
|
|
<div className="flex justify-center py-1 border-t border-border/30">
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
|
</div>
|
|
)}
|
|
|
|
<SidebarFooter
|
|
sidebarOpen={sidebarOpen}
|
|
isActiveRoute={isActiveRoute}
|
|
navigate={navigate}
|
|
hideRunningAgents={hideRunningAgents}
|
|
hideWiki={hideWiki}
|
|
runningAgentsCount={runningAgentsCount}
|
|
shortcuts={{ settings: shortcuts.settings }}
|
|
/>
|
|
|
|
<TrashDialog
|
|
open={showTrashDialog}
|
|
onOpenChange={setShowTrashDialog}
|
|
trashedProjects={trashedProjects}
|
|
activeTrashId={activeTrashId}
|
|
handleRestoreProject={handleRestoreProject}
|
|
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
|
deleteTrashedProject={deleteTrashedProject}
|
|
handleEmptyTrash={handleEmptyTrash}
|
|
isEmptyingTrash={isEmptyingTrash}
|
|
/>
|
|
|
|
{/* New Project Setup Dialog */}
|
|
<CreateSpecDialog
|
|
open={showSetupDialog}
|
|
onOpenChange={setShowSetupDialog}
|
|
projectOverview={projectOverview}
|
|
onProjectOverviewChange={setProjectOverview}
|
|
generateFeatures={generateFeatures}
|
|
onGenerateFeaturesChange={setGenerateFeatures}
|
|
analyzeProject={analyzeProject}
|
|
onAnalyzeProjectChange={setAnalyzeProject}
|
|
featureCount={featureCount}
|
|
onFeatureCountChange={setFeatureCount}
|
|
onCreateSpec={handleCreateInitialSpec}
|
|
onSkip={handleSkipSetup}
|
|
isCreatingSpec={isCreatingSpec}
|
|
showSkipButton={true}
|
|
title="Set Up Your Project"
|
|
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
|
|
/>
|
|
|
|
<OnboardingDialog
|
|
open={showOnboardingDialog}
|
|
onOpenChange={setShowOnboardingDialog}
|
|
newProjectName={newProjectName}
|
|
onSkip={handleOnboardingSkip}
|
|
onGenerateSpec={handleOnboardingGenerateSpec}
|
|
/>
|
|
|
|
{/* Delete Project Confirmation Dialog */}
|
|
<DeleteProjectDialog
|
|
open={showDeleteProjectDialog}
|
|
onOpenChange={setShowDeleteProjectDialog}
|
|
project={currentProject}
|
|
onConfirm={moveProjectToTrash}
|
|
/>
|
|
|
|
{/* New Project Modal */}
|
|
<NewProjectModal
|
|
open={showNewProjectModal}
|
|
onOpenChange={setShowNewProjectModal}
|
|
onCreateBlankProject={handleCreateBlankProject}
|
|
onCreateFromTemplate={handleCreateFromTemplate}
|
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
|
isCreating={isCreatingProject}
|
|
/>
|
|
</aside>
|
|
|
|
{/* Context Menu */}
|
|
{contextMenuProject && contextMenuPosition && (
|
|
<ProjectContextMenu
|
|
project={contextMenuProject}
|
|
position={contextMenuPosition}
|
|
onClose={handleCloseContextMenu}
|
|
onEdit={handleEditProject}
|
|
/>
|
|
)}
|
|
|
|
{/* Edit Project Dialog */}
|
|
{editDialogProject && (
|
|
<EditProjectDialog
|
|
project={editDialogProject}
|
|
open={!!editDialogProject}
|
|
onOpenChange={(open) => !open && setEditDialogProject(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|