Merge pull request #1 from webdevcody/trachcan-option

feat(sidebar): implement trash functionality for project management
This commit is contained in:
Web Dev Cody
2025-12-09 22:48:01 -05:00
committed by GitHub
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 // Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") }); 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 fs = require("fs/promises");
const agentService = require("./agent-service"); const agentService = require("./agent-service");
const autoModeService = require("./auto-mode-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 // App data path
ipcMain.handle("app:getPath", (_, name) => { ipcMain.handle("app:getPath", (_, name) => {
return app.getPath(name); return app.getPath(name);
@@ -193,7 +202,9 @@ ipcMain.handle(
await fs.mkdir(imagesDir, { recursive: true }); await fs.mkdir(imagesDir, { recursive: true });
// Generate unique filename with unique ID // 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 safeName = filename.replace(/[^a-zA-Z0-9.-]/g, "_");
const imageFilePath = path.join(imagesDir, `${uniqueId}_${safeName}`); const imageFilePath = path.join(imagesDir, `${uniqueId}_${safeName}`);

View File

@@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath), exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath), stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath),
deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath), deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath),
trashItem: (filePath) => ipcRenderer.invoke("fs:trashItem", filePath),
// App APIs // App APIs
getPath: (name) => ipcRenderer.invoke("app:getPath", name), getPath: (name) => ipcRenderer.invoke("app:getPath", name),

View File

@@ -3,7 +3,6 @@
import { useState, useMemo, useEffect, useCallback } from "react"; import { useState, useMemo, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import Link from "next/link";
import { import {
FolderOpen, FolderOpen,
Plus, Plus,
@@ -23,13 +22,25 @@ import {
Check, Check,
BookOpen, BookOpen,
GripVertical, GripVertical,
Trash2,
Undo2,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { import {
useKeyboardShortcuts, useKeyboardShortcuts,
NAV_SHORTCUTS, NAV_SHORTCUTS,
@@ -37,7 +48,7 @@ import {
ACTION_SHORTCUTS, ACTION_SHORTCUTS,
KeyboardShortcut, KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts"; } 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 { initializeProject } from "@/lib/project-init";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -73,6 +84,7 @@ interface SortableProjectItemProps {
index: number; index: number;
currentProjectId: string | undefined; currentProjectId: string | undefined;
onSelect: (project: Project) => void; onSelect: (project: Project) => void;
onTrash: (project: Project) => void;
} }
function SortableProjectItem({ function SortableProjectItem({
@@ -80,6 +92,7 @@ function SortableProjectItem({
index, index,
currentProjectId, currentProjectId,
onSelect, onSelect,
onTrash,
}: SortableProjectItemProps) { }: SortableProjectItemProps) {
const { const {
attributes, attributes,
@@ -138,6 +151,19 @@ function SortableProjectItem({
<Check className="h-4 w-4 text-brand-500 shrink-0" /> <Check className="h-4 w-4 text-brand-500 shrink-0" />
)} )}
</div> </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> </div>
); );
} }
@@ -145,6 +171,7 @@ function SortableProjectItem({
export function Sidebar() { export function Sidebar() {
const { const {
projects, projects,
trashedProjects,
currentProject, currentProject,
currentView, currentView,
sidebarOpen, sidebarOpen,
@@ -152,12 +179,18 @@ export function Sidebar() {
setCurrentProject, setCurrentProject,
setCurrentView, setCurrentView,
toggleSidebar, toggleSidebar,
removeProject, moveProjectToTrash,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
reorderProjects, reorderProjects,
} = useAppStore(); } = useAppStore();
// State for project picker dropdown // State for project picker dropdown
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); 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 // Sensors for drag-and-drop
const sensors = useSensors( const sensors = useSensors(
@@ -239,6 +272,89 @@ export function Sidebar() {
} }
}, [addProject, setCurrentProject]); }, [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[] = [ const navSections: NavSection[] = [
{ {
label: "Project", label: "Project",
@@ -530,10 +646,23 @@ export function Sidebar() {
setCurrentProject(p); setCurrentProject(p);
setIsProjectPickerOpen(false); setIsProjectPickerOpen(false);
}} }}
onTrash={handleTrashProject}
/> />
))} ))}
</SortableContext> </SortableContext>
</DndContext> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -638,8 +767,38 @@ export function Sidebar() {
{/* Bottom Section - User / Settings */} {/* Bottom Section - User / Settings */}
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0"> <div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Settings Link */} {/* Trash + Settings Links */}
<div className="p-2"> <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 <button
onClick={() => setCurrentView("settings")} onClick={() => setCurrentView("settings")}
className={cn( className={cn(
@@ -691,6 +850,91 @@ export function Sidebar() {
</button> </button>
</div> </div>
</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> </aside>
); );
} }

View File

@@ -89,6 +89,7 @@ export interface ElectronAPI {
exists: (filePath: string) => Promise<boolean>; exists: (filePath: string) => Promise<boolean>;
stat: (filePath: string) => Promise<StatResult>; stat: (filePath: string) => Promise<StatResult>;
deleteFile: (filePath: string) => Promise<WriteResult>; deleteFile: (filePath: string) => Promise<WriteResult>;
trashItem?: (filePath: string) => Promise<WriteResult>;
getPath: (name: string) => Promise<string>; getPath: (name: string) => Promise<string>;
saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>; saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>;
autoMode?: AutoModeAPI; autoMode?: AutoModeAPI;
@@ -115,6 +116,7 @@ const mockFeatures = [
const STORAGE_KEYS = { const STORAGE_KEYS = {
PROJECTS: "automaker_projects", PROJECTS: "automaker_projects",
CURRENT_PROJECT: "automaker_current_project", CURRENT_PROJECT: "automaker_current_project",
TRASHED_PROJECTS: "automaker_trashed_projects",
} as const; } as const;
// Mock file system using localStorage // Mock file system using localStorage
@@ -335,6 +337,10 @@ export const getElectronAPI = (): ElectronAPI => {
return { success: true }; return { success: true };
}, },
trashItem: async () => {
return { success: true };
},
getPath: async (name: string) => { getPath: async (name: string) => {
if (name === "userData") { if (name === "userData") {
return "/mock/userData"; return "/mock/userData";
@@ -771,6 +777,11 @@ export interface Project {
lastOpened?: string; lastOpened?: string;
} }
export interface TrashedProject extends Project {
trashedAt: string;
deletedFromDisk?: boolean;
}
export const getStoredProjects = (): Project[] => { export const getStoredProjects = (): Project[] => {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS); 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); const projects = getStoredProjects().filter((p) => p.id !== projectId);
saveProjects(projects); 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 { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { Project } from "@/lib/electron"; import type { Project, TrashedProject } from "@/lib/electron";
export type ViewMode = export type ViewMode =
| "welcome" | "welcome"
@@ -93,6 +93,7 @@ export interface AppState {
// Project state // Project state
projects: Project[]; projects: Project[];
currentProject: Project | null; currentProject: Project | null;
trashedProjects: TrashedProject[];
// View state // View state
currentView: ViewMode; currentView: ViewMode;
@@ -155,6 +156,10 @@ export interface AppActions {
setProjects: (projects: Project[]) => void; setProjects: (projects: Project[]) => void;
addProject: (project: Project) => void; addProject: (project: Project) => void;
removeProject: (projectId: string) => void; removeProject: (projectId: string) => void;
moveProjectToTrash: (projectId: string) => void;
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
setCurrentProject: (project: Project | null) => void; setCurrentProject: (project: Project | null) => void;
reorderProjects: (oldIndex: number, newIndex: number) => void; reorderProjects: (oldIndex: number, newIndex: number) => void;
@@ -217,6 +222,7 @@ export interface AppActions {
const initialState: AppState = { const initialState: AppState = {
projects: [], projects: [],
currentProject: null, currentProject: null,
trashedProjects: [],
currentView: "welcome", currentView: "welcome",
sidebarOpen: true, sidebarOpen: true,
theme: "dark", theme: "dark",
@@ -270,6 +276,82 @@ export const useAppStore = create<AppState & AppActions>()(
set({ projects: get().projects.filter((p) => p.id !== projectId) }); 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) => { reorderProjects: (oldIndex, newIndex) => {
const projects = [...get().projects]; const projects = [...get().projects];
const [movedProject] = projects.splice(oldIndex, 1); const [movedProject] = projects.splice(oldIndex, 1);
@@ -496,6 +578,7 @@ export const useAppStore = create<AppState & AppActions>()(
partialize: (state) => ({ partialize: (state) => ({
projects: state.projects, projects: state.projects,
currentProject: state.currentProject, currentProject: state.currentProject,
trashedProjects: state.trashedProjects,
currentView: state.currentView, currentView: state.currentView,
theme: state.theme, theme: state.theme,
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,