Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/ui/src/components/dialogs/project-path-validation-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { FolderX, RefreshCw, Trash2, AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import type { Project } from '@/lib/electron';
import { useFileBrowser } from '@/contexts/file-browser-context';

interface ProjectPathValidationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onRefreshPath: (project: Project, newPath: string) => Promise<void>;
onRemoveProject: (project: Project) => void;
onDismiss?: () => void;
}

export function ProjectPathValidationDialog({
open,
onOpenChange,
project,
onRefreshPath,
onRemoveProject,
onDismiss,
}: ProjectPathValidationDialogProps) {
const { openFileBrowser } = useFileBrowser();

const handleRefreshPath = async () => {
if (!project) return;

const newPath = await openFileBrowser({
title: 'Select New Project Location',
description: 'Choose the new directory for this project',
initialPath: project.path,
});

if (!newPath) {
// User cancelled - stay on dialog
return;
}

await onRefreshPath(project, newPath);
};

const handleRemoveProject = () => {
if (!project) return;
onRemoveProject(project);
onOpenChange(false);
};

const handleDismiss = () => {
onDismiss?.();
onOpenChange(false);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md gap-4 shadow-xl border-destructive/20 p-5"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader className="space-y-1">
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
<FolderX className="w-4 h-4 text-destructive" />
</div>
<DialogTitle className="text-lg">Project Path Not Found</DialogTitle>
</div>
<DialogDescription>
The project directory cannot be found at its saved location.
</DialogDescription>
</DialogHeader>

{project && (
<div className="space-y-3">
<div className="bg-muted/30 border rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
<div className="space-y-0.5 min-w-0 flex-1">
<p className="font-medium text-sm leading-none truncate">{project.name}</p>
<p className="text-xs font-mono text-muted-foreground break-all opacity-80">
{project.path}
</p>
</div>
</div>
</div>

<p className="text-xs text-muted-foreground">
Select the new location if it was moved, or remove it from your list.
</p>
</div>
)}

<DialogFooter className="gap-2 sm:gap-2 mt-2">
<Button variant="ghost" onClick={handleDismiss} size="sm" className="h-9 px-3">
Dismiss
</Button>
<Button
variant="outline"
onClick={handleRemoveProject}
size="sm"
className="h-9 px-3 text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/20 hover:border-destructive/30"
>
<Trash2 className="w-4 h-4 mr-2" />
Remove
</Button>
<HotkeyButton
variant="default"
onClick={handleRefreshPath}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
size="sm"
className="h-9 px-3"
>
<RefreshCw className="w-4 h-4 mr-2" />
Locate Project
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
7 changes: 5 additions & 2 deletions apps/ui/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useValidatedProjectCycling } from '@/hooks/use-validated-project-cycling';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
Expand Down Expand Up @@ -51,8 +52,6 @@ export function Sidebar() {
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
Expand All @@ -74,6 +73,9 @@ export function Sidebar() {
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);

// Validated project cycling (automatically skips invalid paths)
const { cyclePrevProject, cycleNextProject } = useValidatedProjectCycling();

// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();

Expand Down Expand Up @@ -148,6 +150,7 @@ export function Sidebar() {
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});

// Spec regeneration events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useValidatedProjectCycling } from '@/hooks/use-validated-project-cycling';
import { useProjectPathValidation } from '@/hooks/use-project-path-validation';
import { validateProjectPath } from '@/lib/validate-project-path';
import { ProjectPathValidationDialog } from '@/components/dialogs/project-path-validation-dialog';

interface ProjectSelectorWithOptionsProps {
sidebarOpen: boolean;
Expand All @@ -53,12 +57,22 @@ export function ProjectSelectorWithOptions({
projectHistory,
setCurrentProject,
reorderProjects,
cyclePrevProject,
cycleNextProject,
clearProjectHistory,
} = useAppStore();

const shortcuts = useKeyboardShortcutsConfig();

const {
validationDialogOpen,
setValidationDialogOpen,
invalidProject,
showValidationDialog,
handleRefreshPath,
handleRemoveProject,
} = useProjectPathValidation({ navigateOnRefresh: true });

// Validated project cycling (automatically skips invalid paths)
const { cyclePrevProject, cycleNextProject } = useValidatedProjectCycling();
const {
projectSearchQuery,
setProjectSearchQuery,
Expand Down Expand Up @@ -182,9 +196,17 @@ export function ProjectSelectorWithOptions({
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
onSelect={async (p) => {
setIsProjectPickerOpen(false);

// Validate path before switching
const isValid = await validateProjectPath(p);

if (!isValid) {
showValidationDialog(p);
} else {
setCurrentProject(p);
}
}}
/>
))}
Expand Down Expand Up @@ -367,6 +389,14 @@ export function ProjectSelectorWithOptions({
</DropdownMenuContent>
</DropdownMenu>
)}

<ProjectPathValidationDialog
open={validationDialogOpen}
onOpenChange={setValidationDialogOpen}
project={invalidProject}
onRefreshPath={handleRefreshPath}
onRemoveProject={handleRemoveProject}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,43 @@ import { toast } from 'sonner';

const logger = createLogger('TrashOperations');
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
import { validateProjectPath } from '@/lib/validate-project-path';

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) => {
async (projectId: string) => {
try {
const project = trashedProjects.find((p) => p.id === projectId);
if (!project) {
toast.error('Project not found');
return;
}

// Validate project path before restoring
const isValid = await validateProjectPath(project);
if (!isValid) {
toast.error('Project path not found', {
description:
'The project directory no longer exists. Remove it from the recycle bin and re-add the project from its new location.',
});
return;
}

restoreTrashedProject(projectId);
toast.success('Project restored', {
description: 'Added back to your project list.',
Expand All @@ -33,7 +52,7 @@ export function useTrashOperations({
});
}
},
[restoreTrashedProject]
[restoreTrashedProject, trashedProjects]
);

const handleDeleteProjectFromDisk = useCallback(
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ export { useResponsiveKanban } from './use-responsive-kanban';
export { useScrollTracking } from './use-scroll-tracking';
export { useSettingsMigration } from './use-settings-migration';
export { useWindowState } from './use-window-state';
export { useStoreHydration } from './use-store-hydration';
export { useValidatedProjectCycling } from './use-validated-project-cycling';
export { useProjectRestoration } from './use-project-restoration';
export { useProjectPathValidation } from './use-project-path-validation';
94 changes: 94 additions & 0 deletions apps/ui/src/hooks/use-project-path-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import type { Project } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { validateProjectPath } from '@/lib/validate-project-path';

interface UseProjectPathValidationOptions {
/**
* Whether to navigate to /board after successful path refresh.
* Defaults to true.
*/
navigateOnRefresh?: boolean;
}

export function useProjectPathValidation(options: UseProjectPathValidationOptions = {}) {
const { navigateOnRefresh = true } = options;
const navigate = useNavigate();
const { projects, setProjects, setCurrentProject, removeProject } = useAppStore();

const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const [invalidProject, setInvalidProject] = useState<Project | null>(null);

const showValidationDialog = useCallback((project: Project) => {
setInvalidProject(project);
setValidationDialogOpen(true);
}, []);

const handleRefreshPath = useCallback(
async (project: Project, newPath: string) => {
try {
// Validate new path
const isValid = await validateProjectPath({ ...project, path: newPath });

if (!isValid) {
toast.error('Invalid path', {
description: 'Selected path does not exist or is not accessible',
});
return; // Stay on dialog
}

// Update project in store
const updatedProject = { ...project, path: newPath, lastOpened: new Date().toISOString() };
const updatedProjects = projects.map((p) => (p.id === project.id ? updatedProject : p));
setProjects(updatedProjects);

// Update current project reference
setCurrentProject(updatedProject);

// Close dialog
setValidationDialogOpen(false);

// Navigate to board if requested
if (navigateOnRefresh) {
navigate({ to: '/board' });
}

toast.success('Project path updated');
} catch (error) {
console.error('Failed to update project path:', error);
toast.error('Failed to update path', {
description: 'An unexpected error occurred. Please try again.',
});
}
},
[projects, setProjects, setCurrentProject, navigate, navigateOnRefresh]
);

const handleRemoveProject = useCallback(
(project: Project) => {
removeProject(project.id);
setCurrentProject(null);
setValidationDialogOpen(false);
navigate({ to: '/' });
toast.info('Project removed', { description: project.name });
},
[removeProject, setCurrentProject, navigate]
);

const handleDismiss = useCallback(() => {
setCurrentProject(null);
setValidationDialogOpen(false);
}, [setCurrentProject]);

return {
validationDialogOpen,
setValidationDialogOpen,
invalidProject,
showValidationDialog,
handleRefreshPath,
handleRemoveProject,
handleDismiss,
};
}
Loading
Loading