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.
This commit is contained in:
SuperComboGamer
2025-12-09 22:37:04 -05:00
parent 7b760090e4
commit d014a0ba4f
5 changed files with 368 additions and 7 deletions

View File

@@ -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}`);

View File

@@ -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),

View File

@@ -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({
<Check className="h-4 w-4 text-brand-500 shrink-0" />
)}
</div>
{/* Move to trash */}
<button
onClick={(e) => {
e.stopPropagation();
onTrash(project);
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Move to Trash"
data-testid={`project-trash-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
}
@@ -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<string | null>(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}
/>
))}
</SortableContext>
</DndContext>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowTrashDialog(true);
}}
className="text-destructive focus:text-destructive"
data-testid="manage-trash"
>
<Trash2 className="h-4 w-4 mr-2" />
Manage Trash ({trashedProjects.length})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -638,8 +767,38 @@ export function Sidebar() {
{/* Bottom Section - User / Settings */}
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Settings Link */}
{/* Trash + Settings Links */}
<div className="p-2">
<button
onClick={() => setShowTrashDialog(true)}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag mb-2",
"text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Trash" : undefined}
data-testid="trash-button"
>
<Trash2 className="w-4 h-4 shrink-0 transition-colors group-hover:text-destructive" />
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Trash
</span>
{trashedProjects.length > 0 && sidebarOpen && (
<span className="hidden lg:flex items-center justify-center min-w-[20px] px-1 h-5 text-[10px] font-mono rounded bg-destructive/10 border border-destructive/20 text-destructive">
{trashedProjects.length}
</span>
)}
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Trash ({trashedProjects.length})
</span>
)}
</button>
<button
onClick={() => setCurrentView("settings")}
className={cn(
@@ -691,6 +850,91 @@ export function Sidebar() {
</button>
</div>
</div>
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
<DialogContent className="bg-popover border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Trash</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your
system Trash.
</DialogDescription>
</DialogHeader>
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Trash is empty.</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
<div
key={project.id}
className="flex items-start justify-between gap-3 rounded-md border border-sidebar-border bg-sidebar-accent/20 p-3"
>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground break-all">
{project.path}
</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id
? "Deleting..."
: "Delete from disk"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => setShowTrashDialog(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={handleEmptyTrash}
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? "Clearing..." : "Empty Trash"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</aside>
);
}

View File

@@ -89,6 +89,7 @@ export interface ElectronAPI {
exists: (filePath: string) => Promise<boolean>;
stat: (filePath: string) => Promise<StatResult>;
deleteFile: (filePath: string) => Promise<WriteResult>;
trashItem?: (filePath: string) => Promise<WriteResult>;
getPath: (name: string) => Promise<string>;
saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>;
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));
};

View File

@@ -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<AppState & AppActions>()(
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<AppState & AppActions>()(
partialize: (state) => ({
projects: state.projects,
currentProject: state.currentProject,
trashedProjects: state.trashedProjects,
currentView: state.currentView,
theme: state.theme,
sidebarOpen: state.sidebarOpen,