mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Merge pull request #1 from webdevcody/trachcan-option
feat(sidebar): implement trash functionality for project management
This commit is contained in:
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user