From d014a0ba4fcbd9451feb7013457636f0c143be0a Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Tue, 9 Dec 2025 22:37:04 -0500 Subject: [PATCH] feat(sidebar): implement trash functionality for project management - Added a new `trashItem` method in the Electron API to move projects to the system trash. - Enhanced the sidebar component to include a trash button and manage trashed projects. - Implemented functionality to restore and permanently delete projects from the trash. - Updated the application state to track trashed projects and provide user feedback through toast notifications. This update significantly improves project management by allowing users to easily manage deleted projects without permanent loss. --- app/electron/main.js | 15 +- app/electron/preload.js | 1 + app/src/components/layout/sidebar.tsx | 252 +++++++++++++++++++++++++- app/src/lib/electron.ts | 22 +++ app/src/store/app-store.ts | 85 ++++++++- 5 files changed, 368 insertions(+), 7 deletions(-) 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 bab8ca63..e938a048 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 d7e983d6..dbac63cc 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" @@ -92,6 +92,7 @@ export interface AppState { // Project state projects: Project[]; currentProject: Project | null; + trashedProjects: TrashedProject[]; // View state currentView: ViewMode; @@ -154,6 +155,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; @@ -216,6 +221,7 @@ export interface AppActions { const initialState: AppState = { projects: [], currentProject: null, + trashedProjects: [], currentView: "welcome", sidebarOpen: true, theme: "dark", @@ -269,6 +275,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); @@ -495,6 +577,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,