diff --git a/app/electron/main.js b/app/electron/main.js index 1b8f276a..2d8639b5 100644 --- a/app/electron/main.js +++ b/app/electron/main.js @@ -3,7 +3,7 @@ const path = require("path"); // Load environment variables from .env file require("dotenv").config({ path: path.join(__dirname, "../.env") }); -const { app, BrowserWindow, ipcMain, dialog } = require("electron"); +const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); const fs = require("fs/promises"); const agentService = require("./agent-service"); const autoModeService = require("./auto-mode-service"); @@ -169,6 +169,15 @@ ipcMain.handle("fs:deleteFile", async (_, filePath) => { } }); +ipcMain.handle("fs:trashItem", async (_, targetPath) => { + try { + await shell.trashItem(targetPath); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + // App data path ipcMain.handle("app:getPath", (_, name) => { return app.getPath(name); @@ -193,7 +202,9 @@ ipcMain.handle( await fs.mkdir(imagesDir, { recursive: true }); // Generate unique filename with unique ID - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const uniqueId = `${Date.now()}-${Math.random() + .toString(36) + .substring(2, 11)}`; const safeName = filename.replace(/[^a-zA-Z0-9.-]/g, "_"); const imageFilePath = path.join(imagesDir, `${uniqueId}_${safeName}`); diff --git a/app/electron/preload.js b/app/electron/preload.js index 0e47bfee..18a1eaeb 100644 --- a/app/electron/preload.js +++ b/app/electron/preload.js @@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld("electronAPI", { exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath), stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath), deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath), + trashItem: (filePath) => ipcRenderer.invoke("fs:trashItem", filePath), // App APIs getPath: (name) => ipcRenderer.invoke("app:getPath", name), diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index 67fa4c2a..090c3965 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -3,7 +3,6 @@ import { useState, useMemo, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/store/app-store"; -import Link from "next/link"; import { FolderOpen, Plus, @@ -23,13 +22,25 @@ import { Check, BookOpen, GripVertical, + Trash2, + Undo2, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { useKeyboardShortcuts, NAV_SHORTCUTS, @@ -37,7 +48,7 @@ import { ACTION_SHORTCUTS, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project } from "@/lib/electron"; +import { getElectronAPI, Project, TrashedProject } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; import { toast } from "sonner"; import { @@ -73,6 +84,7 @@ interface SortableProjectItemProps { index: number; currentProjectId: string | undefined; onSelect: (project: Project) => void; + onTrash: (project: Project) => void; } function SortableProjectItem({ @@ -80,6 +92,7 @@ function SortableProjectItem({ index, currentProjectId, onSelect, + onTrash, }: SortableProjectItemProps) { const { attributes, @@ -138,6 +151,19 @@ function SortableProjectItem({ )} + + {/* Move to trash */} + ); } @@ -145,6 +171,7 @@ function SortableProjectItem({ export function Sidebar() { const { projects, + trashedProjects, currentProject, currentView, sidebarOpen, @@ -152,12 +179,18 @@ export function Sidebar() { setCurrentProject, setCurrentView, toggleSidebar, - removeProject, + moveProjectToTrash, + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, reorderProjects, } = useAppStore(); // State for project picker dropdown const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); + const [showTrashDialog, setShowTrashDialog] = useState(false); + const [activeTrashId, setActiveTrashId] = useState(null); + const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); // Sensors for drag-and-drop const sensors = useSensors( @@ -239,6 +272,89 @@ export function Sidebar() { } }, [addProject, setCurrentProject]); + const handleTrashProject = useCallback( + (project: Project) => { + const confirmed = window.confirm( + `Move "${project.name}" to Trash?\nThe folder stays on disk until you delete it from Trash.` + ); + if (!confirmed) return; + + moveProjectToTrash(project.id); + setIsProjectPickerOpen(false); + toast.success("Project moved to Trash", { + description: `${project.name} was removed from the sidebar.`, + }); + }, + [moveProjectToTrash] + ); + + 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 trashed projects from Automaker? This does not delete folders from disk." + ); + if (!confirmed) return; + + setIsEmptyingTrash(true); + try { + emptyTrash(); + toast.success("Trash cleared"); + setShowTrashDialog(false); + } finally { + setIsEmptyingTrash(false); + } + }, [emptyTrash, trashedProjects.length]); + const navSections: NavSection[] = [ { label: "Project", @@ -530,10 +646,23 @@ export function Sidebar() { setCurrentProject(p); setIsProjectPickerOpen(false); }} + onTrash={handleTrashProject} /> ))} + + { + e.preventDefault(); + setShowTrashDialog(true); + }} + className="text-destructive focus:text-destructive" + data-testid="manage-trash" + > + + Manage Trash ({trashedProjects.length}) + @@ -638,8 +767,38 @@ export function Sidebar() { {/* Bottom Section - User / Settings */}
- {/* Settings Link */} + {/* Trash + Settings Links */}
+
+ + + + Trash + + Restore projects to the sidebar or delete their folders using your + system Trash. + + + + {trashedProjects.length === 0 ? ( +

Trash is empty.

+ ) : ( +
+ {trashedProjects.map((project) => ( +
+
+

+ {project.name} +

+

+ {project.path} +

+

+ Trashed {new Date(project.trashedAt).toLocaleString()} +

+
+
+ + + +
+
+ ))} +
+ )} + + + + {trashedProjects.length > 0 && ( + + )} + +
+
); } diff --git a/app/src/lib/electron.ts b/app/src/lib/electron.ts index 8ef9642c..ccadd497 100644 --- a/app/src/lib/electron.ts +++ b/app/src/lib/electron.ts @@ -89,6 +89,7 @@ export interface ElectronAPI { exists: (filePath: string) => Promise; stat: (filePath: string) => Promise; deleteFile: (filePath: string) => Promise; + trashItem?: (filePath: string) => Promise; getPath: (name: string) => Promise; saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise; autoMode?: AutoModeAPI; @@ -115,6 +116,7 @@ const mockFeatures = [ const STORAGE_KEYS = { PROJECTS: "automaker_projects", CURRENT_PROJECT: "automaker_current_project", + TRASHED_PROJECTS: "automaker_trashed_projects", } as const; // Mock file system using localStorage @@ -335,6 +337,10 @@ export const getElectronAPI = (): ElectronAPI => { return { success: true }; }, + trashItem: async () => { + return { success: true }; + }, + getPath: async (name: string) => { if (name === "userData") { return "/mock/userData"; @@ -771,6 +777,11 @@ export interface Project { lastOpened?: string; } +export interface TrashedProject extends Project { + trashedAt: string; + deletedFromDisk?: boolean; +} + export const getStoredProjects = (): Project[] => { if (typeof window === "undefined") return []; const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS); @@ -812,3 +823,14 @@ export const removeProject = (projectId: string): void => { const projects = getStoredProjects().filter((p) => p.id !== projectId); saveProjects(projects); }; + +export const getStoredTrashedProjects = (): TrashedProject[] => { + if (typeof window === "undefined") return []; + const stored = localStorage.getItem(STORAGE_KEYS.TRASHED_PROJECTS); + return stored ? JSON.parse(stored) : []; +}; + +export const saveTrashedProjects = (projects: TrashedProject[]): void => { + if (typeof window === "undefined") return; + localStorage.setItem(STORAGE_KEYS.TRASHED_PROJECTS, JSON.stringify(projects)); +}; diff --git a/app/src/store/app-store.ts b/app/src/store/app-store.ts index d1b537e5..28f90038 100644 --- a/app/src/store/app-store.ts +++ b/app/src/store/app-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import type { Project } from "@/lib/electron"; +import type { Project, TrashedProject } from "@/lib/electron"; export type ViewMode = | "welcome" @@ -93,6 +93,7 @@ export interface AppState { // Project state projects: Project[]; currentProject: Project | null; + trashedProjects: TrashedProject[]; // View state currentView: ViewMode; @@ -155,6 +156,10 @@ export interface AppActions { setProjects: (projects: Project[]) => void; addProject: (project: Project) => void; removeProject: (projectId: string) => void; + moveProjectToTrash: (projectId: string) => void; + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; setCurrentProject: (project: Project | null) => void; reorderProjects: (oldIndex: number, newIndex: number) => void; @@ -217,6 +222,7 @@ export interface AppActions { const initialState: AppState = { projects: [], currentProject: null, + trashedProjects: [], currentView: "welcome", sidebarOpen: true, theme: "dark", @@ -270,6 +276,82 @@ export const useAppStore = create()( set({ projects: get().projects.filter((p) => p.id !== projectId) }); }, + moveProjectToTrash: (projectId) => { + const project = get().projects.find((p) => p.id === projectId); + if (!project) return; + + const remainingProjects = get().projects.filter( + (p) => p.id !== projectId + ); + const existingTrash = get().trashedProjects.filter( + (p) => p.id !== projectId + ); + const trashedProject: TrashedProject = { + ...project, + trashedAt: new Date().toISOString(), + deletedFromDisk: false, + }; + + const isCurrent = get().currentProject?.id === projectId; + + set({ + projects: remainingProjects, + trashedProjects: [trashedProject, ...existingTrash], + currentProject: isCurrent ? null : get().currentProject, + currentView: isCurrent ? "welcome" : get().currentView, + }); + }, + + restoreTrashedProject: (projectId) => { + const trashed = get().trashedProjects.find((p) => p.id === projectId); + if (!trashed) return; + + const remainingTrash = get().trashedProjects.filter( + (p) => p.id !== projectId + ); + const existingProjects = get().projects; + const samePathProject = existingProjects.find( + (p) => p.path === trashed.path + ); + const projectsWithoutId = existingProjects.filter( + (p) => p.id !== projectId + ); + + // If a project with the same path already exists, keep it and just remove from trash + if (samePathProject) { + set({ + trashedProjects: remainingTrash, + currentProject: samePathProject, + currentView: "board", + }); + return; + } + + const restoredProject: Project = { + id: trashed.id, + name: trashed.name, + path: trashed.path, + lastOpened: new Date().toISOString(), + }; + + set({ + trashedProjects: remainingTrash, + projects: [...projectsWithoutId, restoredProject], + currentProject: restoredProject, + currentView: "board", + }); + }, + + deleteTrashedProject: (projectId) => { + set({ + trashedProjects: get().trashedProjects.filter( + (p) => p.id !== projectId + ), + }); + }, + + emptyTrash: () => set({ trashedProjects: [] }), + reorderProjects: (oldIndex, newIndex) => { const projects = [...get().projects]; const [movedProject] = projects.splice(oldIndex, 1); @@ -496,6 +578,7 @@ export const useAppStore = create()( partialize: (state) => ({ projects: state.projects, currentProject: state.currentProject, + trashedProjects: state.trashedProjects, currentView: state.currentView, theme: state.theme, sidebarOpen: state.sidebarOpen,