mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
refactor: enhance sidebar functionality with new hooks and components
- Introduced new hooks: useRunningAgents, useTrashOperations, useProjectPicker, useSpecRegeneration, and useNavigation for improved state management and functionality. - Created CollapseToggleButton component for sidebar collapse functionality, enhancing UI responsiveness. - Refactored sidebar.tsx to utilize the new hooks and components, improving code organization and maintainability. - Updated sidebar structure to streamline project selection and navigation processes. This refactor aims to enhance user experience and maintainability by modularizing functionality and improving the sidebar's responsiveness.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store';
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Settings,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
Folder,
|
||||
X,
|
||||
PanelLeft,
|
||||
@@ -16,12 +13,10 @@ import {
|
||||
ChevronDown,
|
||||
Redo2,
|
||||
Check,
|
||||
BookOpen,
|
||||
GripVertical,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Undo2,
|
||||
UserCircle,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Monitor,
|
||||
@@ -31,7 +26,6 @@ import {
|
||||
Recycle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Rocket,
|
||||
Zap,
|
||||
CheckCircle2,
|
||||
@@ -61,16 +55,11 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } 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/dialogs/new-project-modal';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
@@ -81,14 +70,27 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
|
||||
// Local imports from subfolder
|
||||
import type { NavSection, NavItem } from './sidebar/types';
|
||||
import { SortableProjectItem, ThemeMenuItem, BugReportButton } from './sidebar/components';
|
||||
import {
|
||||
SortableProjectItem,
|
||||
ThemeMenuItem,
|
||||
BugReportButton,
|
||||
CollapseToggleButton,
|
||||
} from './sidebar/components';
|
||||
import {
|
||||
PROJECT_DARK_THEMES,
|
||||
PROJECT_LIGHT_THEMES,
|
||||
SIDEBAR_FEATURE_FLAGS,
|
||||
} from './sidebar/constants';
|
||||
import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks';
|
||||
import {
|
||||
useThemePreview,
|
||||
useSidebarAutoCollapse,
|
||||
useDragAndDrop,
|
||||
useRunningAgents,
|
||||
useTrashOperations,
|
||||
useProjectPicker,
|
||||
useSpecRegeneration,
|
||||
useNavigation,
|
||||
} from './sidebar/hooks';
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
@@ -128,18 +130,11 @@ export function Sidebar() {
|
||||
|
||||
// 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<string | null>(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);
|
||||
@@ -165,132 +160,56 @@ export function Sidebar() {
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
const creatingSpecProjectPath = specCreatingForProject;
|
||||
|
||||
// Ref for project search input
|
||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
useSidebarAutoCollapse({ 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]);
|
||||
// Project picker with search and keyboard navigation
|
||||
const {
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
selectedProjectIndex,
|
||||
setSelectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
filteredProjects,
|
||||
selectHighlightedProject,
|
||||
} = useProjectPicker({
|
||||
projects,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
});
|
||||
|
||||
// Drag-and-drop for project reordering
|
||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
// Running agents count
|
||||
const { runningAgentsCount } = useRunningAgents();
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||
console.log(
|
||||
'[Sidebar] Spec regeneration event:',
|
||||
event.type,
|
||||
'for project:',
|
||||
event.projectPath
|
||||
);
|
||||
// Trash operations
|
||||
const {
|
||||
activeTrashId,
|
||||
isEmptyingTrash,
|
||||
handleRestoreProject,
|
||||
handleDeleteProjectFromDisk,
|
||||
handleEmptyTrash,
|
||||
} = useTrashOperations({
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
trashedProjects,
|
||||
});
|
||||
|
||||
// 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();
|
||||
};
|
||||
}, [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]);
|
||||
// Spec regeneration events
|
||||
useSpecRegeneration({
|
||||
creatingSpecProjectPath,
|
||||
setupProjectPath,
|
||||
setSpecCreatingForProject,
|
||||
setShowSetupDialog,
|
||||
setProjectOverview,
|
||||
setSetupProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
});
|
||||
|
||||
// Handle creating initial spec for new project
|
||||
const handleCreateInitialSpec = useCallback(async () => {
|
||||
@@ -711,261 +630,23 @@ export function Sidebar() {
|
||||
}
|
||||
}, [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,
|
||||
},
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build project items - Terminal is conditionally included
|
||||
const projectItems: NavItem[] = [
|
||||
{
|
||||
id: 'board',
|
||||
label: 'Kanban Board',
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
icon: Bot,
|
||||
shortcut: shortcuts.agent,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Terminal to Project section if not hidden
|
||||
if (!hideTerminal) {
|
||||
projectItems.push({
|
||||
id: 'terminal',
|
||||
label: 'Terminal',
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Project',
|
||||
items: projectItems,
|
||||
},
|
||||
{
|
||||
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: () => navigate({ to: `/${item.id}` as const }),
|
||||
description: `Navigate to ${item.label}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
shortcutsList.push({
|
||||
key: shortcuts.settings,
|
||||
action: () => navigate({ to: '/settings' }),
|
||||
description: 'Navigate to Settings',
|
||||
});
|
||||
}
|
||||
|
||||
return shortcutsList;
|
||||
}, [
|
||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
||||
const { navSections, navigationShortcuts } = useNavigation({
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
navigate,
|
||||
toggleSidebar,
|
||||
projects.length,
|
||||
handleOpenFolder,
|
||||
projectHistory.length,
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
navSections,
|
||||
]);
|
||||
});
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(navigationShortcuts);
|
||||
@@ -990,49 +671,11 @@ export function Sidebar() {
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
{/* Floating Collapse Toggle Button - Desktop only - At border intersection */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
// Premium shadow with glow on hover
|
||||
'shadow-lg shadow-black/5 hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'hover:border-brand-500/30',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
'hover:scale-110 active:scale-90'
|
||||
)}
|
||||
data-testid="sidebar-collapse-button"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelLeftClose className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
) : (
|
||||
<PanelLeft className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover/toggle:opacity-100 transition-all duration-200',
|
||||
'whitespace-nowrap z-50 pointer-events-none',
|
||||
'translate-x-1 group-hover/toggle:translate-x-0'
|
||||
)}
|
||||
data-testid="sidebar-toggle-tooltip"
|
||||
>
|
||||
{sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}{' '}
|
||||
<span
|
||||
className="ml-1.5 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"
|
||||
data-testid="sidebar-toggle-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcuts.toggleSidebar, true)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<CollapseToggleButton
|
||||
sidebarOpen={sidebarOpen}
|
||||
toggleSidebar={toggleSidebar}
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Logo */}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
|
||||
interface CollapseToggleButtonProps {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
shortcut: string;
|
||||
}
|
||||
|
||||
export function CollapseToggleButton({
|
||||
sidebarOpen,
|
||||
toggleSidebar,
|
||||
shortcut,
|
||||
}: CollapseToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
// Premium shadow with glow on hover
|
||||
'shadow-lg shadow-black/5 hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'hover:border-brand-500/30',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
'hover:scale-110 active:scale-90'
|
||||
)}
|
||||
data-testid="sidebar-collapse-button"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelLeftClose className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
) : (
|
||||
<PanelLeft className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover/toggle:opacity-100 transition-all duration-200',
|
||||
'whitespace-nowrap z-50 pointer-events-none',
|
||||
'translate-x-1 group-hover/toggle:translate-x-0'
|
||||
)}
|
||||
data-testid="sidebar-toggle-tooltip"
|
||||
>
|
||||
{sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}{' '}
|
||||
<span
|
||||
className="ml-1.5 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"
|
||||
data-testid="sidebar-toggle-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcut, true)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { SortableProjectItem } from './sortable-project-item';
|
||||
export { ThemeMenuItem } from './theme-menu-item';
|
||||
export { BugReportButton } from './bug-report-button';
|
||||
export { CollapseToggleButton } from './collapse-toggle-button';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export { useThemePreview } from './use-theme-preview';
|
||||
export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse';
|
||||
export { useDragAndDrop } from './use-drag-and-drop';
|
||||
export { useRunningAgents } from './use-running-agents';
|
||||
export { useTrashOperations } from './use-trash-operations';
|
||||
export { useProjectPicker } from './use-project-picker';
|
||||
export { useSpecRegeneration } from './use-spec-regeneration';
|
||||
export { useNavigation } from './use-navigation';
|
||||
|
||||
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal file
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseNavigationProps {
|
||||
shortcuts: {
|
||||
toggleSidebar: string;
|
||||
openProject: string;
|
||||
projectPicker: string;
|
||||
cyclePrevProject: string;
|
||||
cycleNextProject: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
profiles: string;
|
||||
board: string;
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
};
|
||||
hideSpecEditor: boolean;
|
||||
hideContext: boolean;
|
||||
hideTerminal: boolean;
|
||||
hideAiProfiles: boolean;
|
||||
currentProject: Project | null;
|
||||
projects: Project[];
|
||||
projectHistory: string[];
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
toggleSidebar: () => void;
|
||||
handleOpenFolder: () => void;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
cyclePrevProject: () => void;
|
||||
cycleNextProject: () => void;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
navigate,
|
||||
toggleSidebar,
|
||||
handleOpenFolder,
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
}: UseNavigationProps) {
|
||||
// Build navigation sections
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build project items - Terminal is conditionally included
|
||||
const projectItems: NavItem[] = [
|
||||
{
|
||||
id: 'board',
|
||||
label: 'Kanban Board',
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
icon: Bot,
|
||||
shortcut: shortcuts.agent,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Terminal to Project section if not hidden
|
||||
if (!hideTerminal) {
|
||||
projectItems.push({
|
||||
id: 'terminal',
|
||||
label: 'Terminal',
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Project',
|
||||
items: projectItems,
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
items: visibleToolsItems,
|
||||
},
|
||||
];
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
|
||||
|
||||
// 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: () => navigate({ to: `/${item.id}` as const }),
|
||||
description: `Navigate to ${item.label}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
shortcutsList.push({
|
||||
key: shortcuts.settings,
|
||||
action: () => navigate({ to: '/settings' }),
|
||||
description: 'Navigate to Settings',
|
||||
});
|
||||
}
|
||||
|
||||
return shortcutsList;
|
||||
}, [
|
||||
shortcuts,
|
||||
currentProject,
|
||||
navigate,
|
||||
toggleSidebar,
|
||||
projects.length,
|
||||
handleOpenFolder,
|
||||
projectHistory.length,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
navSections,
|
||||
setIsProjectPickerOpen,
|
||||
]);
|
||||
|
||||
return {
|
||||
navSections,
|
||||
navigationShortcuts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseProjectPickerProps {
|
||||
projects: Project[];
|
||||
isProjectPickerOpen: boolean;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
}
|
||||
|
||||
export function useProjectPicker({
|
||||
projects,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
}: UseProjectPickerProps) {
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||
const projectSearchInputRef = useRef<HTMLInputElement>(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]);
|
||||
|
||||
// Handle selecting the currently highlighted project
|
||||
const selectHighlightedProject = useCallback(() => {
|
||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||
setCurrentProject(filteredProjects[selectedProjectIndex]);
|
||||
setIsProjectPickerOpen(false);
|
||||
}
|
||||
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
||||
|
||||
// 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,
|
||||
setIsProjectPickerOpen,
|
||||
]);
|
||||
|
||||
return {
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
selectedProjectIndex,
|
||||
setSelectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
filteredProjects,
|
||||
selectHighlightedProject,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function useRunningAgents() {
|
||||
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
|
||||
|
||||
// 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]);
|
||||
|
||||
return {
|
||||
runningAgentsCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||
|
||||
interface UseSpecRegenerationProps {
|
||||
creatingSpecProjectPath: string | null;
|
||||
setupProjectPath: string;
|
||||
setSpecCreatingForProject: (path: string | null) => void;
|
||||
setShowSetupDialog: (show: boolean) => void;
|
||||
setProjectOverview: (overview: string) => void;
|
||||
setSetupProjectPath: (path: string) => void;
|
||||
setNewProjectName: (name: string) => void;
|
||||
setNewProjectPath: (path: string) => void;
|
||||
}
|
||||
|
||||
export function useSpecRegeneration({
|
||||
creatingSpecProjectPath,
|
||||
setupProjectPath,
|
||||
setSpecCreatingForProject,
|
||||
setShowSetupDialog,
|
||||
setProjectOverview,
|
||||
setSetupProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
}: UseSpecRegenerationProps) {
|
||||
// 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();
|
||||
};
|
||||
}, [
|
||||
creatingSpecProjectPath,
|
||||
setupProjectPath,
|
||||
setSpecCreatingForProject,
|
||||
setShowSetupDialog,
|
||||
setProjectOverview,
|
||||
setSetupProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface UseTrashOperationsProps {
|
||||
restoreTrashedProject: (projectId: string) => void;
|
||||
deleteTrashedProject: (projectId: string) => void;
|
||||
emptyTrash: () => void;
|
||||
trashedProjects: TrashedProject[];
|
||||
}
|
||||
|
||||
export function useTrashOperations({
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
trashedProjects,
|
||||
}: UseTrashOperationsProps) {
|
||||
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
|
||||
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
|
||||
|
||||
const handleRestoreProject = useCallback(
|
||||
(projectId: string) => {
|
||||
restoreTrashedProject(projectId);
|
||||
toast.success('Project restored', {
|
||||
description: 'Added back to your project list.',
|
||||
});
|
||||
},
|
||||
[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) {
|
||||
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');
|
||||
} finally {
|
||||
setIsEmptyingTrash(false);
|
||||
}
|
||||
}, [emptyTrash, trashedProjects.length]);
|
||||
|
||||
return {
|
||||
activeTrashId,
|
||||
isEmptyingTrash,
|
||||
handleRestoreProject,
|
||||
handleDeleteProjectFromDisk,
|
||||
handleEmptyTrash,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user