From b5fe073cd8b6dc1aaf8abd4fd458ca168f14f1bc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 15:52:56 +0800 Subject: [PATCH] feat: Add folder customization and batch delete for SSH host manager Allow users to customize folder appearance and manage hosts in bulk: Database & Types: - Add sshFolders table with color and icon fields for folder metadata - Add SSHFolder interface to types with userId, name, color, icon fields - Update folder rename route to also update folder metadata API Endpoints: - GET /ssh/folders - Fetch all folders with metadata for user - PUT /ssh/folders/metadata - Create or update folder color and icon - DELETE /ssh/folders/:name/hosts - Delete all hosts in a folder and folder metadata Frontend Features: - Create FolderEditDialog component with color picker (8 colors) and icon selector (10 icons) - Add folder metadata state management in HostManagerViewer - Display custom folder colors and icons in host manager UI - Add "Edit Folder Appearance" button with palette icon - Add "Delete All Hosts in Folder" button with trash icon and confirmation - Fetch and sync folder metadata on component mount and refresh API Functions: - getSSHFolders() - Retrieve all folder metadata - updateFolderMetadata(name, color, icon) - Update folder appearance - deleteAllHostsInFolder(folderName) - Batch delete with count return i18n Support: - Add translations for folder customization (en, zh) - Add batch delete confirmation messages - Add success/error toast messages --- src/backend/database/db/schema.ts | 16 ++ src/backend/database/routes/ssh.ts | 163 +++++++++++++++ src/locales/en/translation.json | 11 + src/locales/zh/translation.json | 11 + src/types/index.ts | 10 + .../apps/host-manager/HostManagerViewer.tsx | 171 +++++++++++++++- .../components/FolderEditDialog.tsx | 189 ++++++++++++++++++ src/ui/main-axios.ts | 85 ++++++++ 8 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 86af0d02..2e5ed460 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -209,6 +209,22 @@ export const snippets = sqliteTable("snippets", { .default(sql`CURRENT_TIMESTAMP`), }); +export const sshFolders = sqliteTable("ssh_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e9cf570..cc33b7a8 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,6 +8,7 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, + sshFolders, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -1341,6 +1342,17 @@ router.put( DatabaseSaveTrigger.triggerSave("folder_rename"); + // Also update folder metadata if exists + await db + .update(sshFolders) + .set({ + name: newName, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)), + ); + res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, @@ -1358,6 +1370,157 @@ router.put( }, ); +// Route: Get all folders with metadata (requires JWT) +// GET /ssh/db/folders +router.get( + "/folders", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + return res.status(400).json({ error: "Invalid user ID" }); + } + + try { + const folders = await db + .select() + .from(sshFolders) + .where(eq(sshFolders.userId, userId)); + + res.json(folders); + } catch (err) { + sshLogger.error("Failed to fetch folders", err, { + operation: "fetch_folders", + userId, + }); + res.status(500).json({ error: "Failed to fetch folders" }); + } + }, +); + +// Route: Update folder metadata (requires JWT) +// PUT /ssh/db/folders/metadata +router.put( + "/folders/metadata", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, color, icon } = req.body; + + if (!isNonEmptyString(userId) || !name) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + // Check if folder metadata exists + const existing = await db + .select() + .from(sshFolders) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .limit(1); + + if (existing.length > 0) { + // Update existing + await db + .update(sshFolders) + .set({ + color, + icon, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), + ); + } else { + // Create new + await db.insert(sshFolders).values({ + userId, + name, + color, + icon, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + DatabaseSaveTrigger.triggerSave("folder_metadata_update"); + + res.json({ message: "Folder metadata updated successfully" }); + } catch (err) { + sshLogger.error("Failed to update folder metadata", err, { + operation: "update_folder_metadata", + userId, + name, + }); + res.status(500).json({ error: "Failed to update folder metadata" }); + } + }, +); + +// Route: Delete all hosts in folder (requires JWT) +// DELETE /ssh/db/folders/:name/hosts +router.delete( + "/folders/:name/hosts", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const folderName = req.params.name; + + if (!isNonEmptyString(userId) || !folderName) { + return res.status(400).json({ error: "Invalid folder name" }); + } + + try { + // Get all hosts in the folder + const hostsToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + if (hostsToDelete.length === 0) { + return res.json({ + message: "No hosts found in folder", + deletedCount: 0, + }); + } + + // Delete all hosts + await db + .delete(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + // Delete folder metadata + await db + .delete(sshFolders) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)), + ); + + DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + + sshLogger.info("Deleted all hosts in folder", { + operation: "delete_folder_hosts", + userId, + folderName, + deletedCount: hostsToDelete.length, + }); + + res.json({ + message: "All hosts in folder deleted successfully", + deletedCount: hostsToDelete.length, + }); + } catch (err) { + sshLogger.error("Failed to delete hosts in folder", err, { + operation: "delete_folder_hosts", + userId, + folderName, + }); + res.status(500).json({ error: "Failed to delete hosts in folder" }); + } + }, +); + // Route: Bulk import SSH hosts (requires JWT) // POST /ssh/bulk-import router.post( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 485c2293..51e96e4e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -754,6 +754,17 @@ "failedToRemoveFromFolder": "Failed to remove host from folder", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", + "editFolderAppearance": "Edit Folder Appearance", + "editFolderAppearanceDesc": "Customize the color and icon for folder", + "folderColor": "Folder Color", + "folderIcon": "Folder Icon", + "preview": "Preview", + "folderAppearanceUpdated": "Folder appearance updated successfully", + "failedToUpdateFolderAppearance": "Failed to update folder appearance", + "deleteAllHostsInFolder": "Delete All Hosts in Folder", + "confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.", + "allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully", + "failedToDeleteHostsInFolder": "Failed to delete hosts in folder", "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", "failedToMoveToFolder": "Failed to move host to folder", "statistics": "Statistics", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 9150f81d..00202b57 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -766,6 +766,17 @@ "failedToRemoveFromFolder": "从文件夹中移除主机失败", "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", "failedToRenameFolder": "重命名文件夹失败", + "editFolderAppearance": "编辑文件夹外观", + "editFolderAppearanceDesc": "自定义文件夹的颜色和图标", + "folderColor": "文件夹颜色", + "folderIcon": "文件夹图标", + "preview": "预览", + "folderAppearanceUpdated": "文件夹外观更新成功", + "failedToUpdateFolderAppearance": "更新文件夹外观失败", + "deleteAllHostsInFolder": "删除文件夹内所有主机", + "confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。", + "allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机", + "failedToDeleteHostsInFolder": "删除文件夹中的主机失败", "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", "failedToMoveToFolder": "移动主机到文件夹失败", "statistics": "统计", diff --git a/src/types/index.ts b/src/types/index.ts index 027de232..cd8a3bc0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,6 +62,16 @@ export interface SSHHostData { terminalConfig?: TerminalConfig; } +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; +} + // ============================================================================ // CREDENTIAL TYPES // ============================================================================ diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index c438b447..ef750f0c 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -22,6 +22,9 @@ import { updateSSHHost, renameFolder, exportSSHHostWithCredentials, + getSSHFolders, + updateFolderMetadata, + deleteAllHostsInFolder, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -45,12 +48,24 @@ import { Copy, Activity, Clock, + Palette, + Trash, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, } from "lucide-react"; import type { SSHHost, + SSHFolder, SSHManagerHostViewerProps, } from "../../../../types/index.js"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; +import { FolderEditDialog } from "./components/FolderEditDialog"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); @@ -65,13 +80,17 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); + const [folderMetadata, setFolderMetadata] = useState>(new Map()); + const [editingFolderAppearance, setEditingFolderAppearance] = useState(null); const dragCounter = useRef(0); useEffect(() => { fetchHosts(); + fetchFolderMetadata(); const handleHostsRefresh = () => { fetchHosts(); + fetchFolderMetadata(); }; window.addEventListener("hosts:refresh", handleHostsRefresh); @@ -116,6 +135,87 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { } }; + const fetchFolderMetadata = async () => { + try { + const folders = await getSSHFolders(); + const metadataMap = new Map(); + folders.forEach((folder) => { + metadataMap.set(folder.name, folder); + }); + setFolderMetadata(metadataMap); + } catch (error) { + console.error("Failed to fetch folder metadata:", error); + } + }; + + const handleSaveFolderAppearance = async (folderName: string, color: string, icon: string) => { + try { + await updateFolderMetadata(folderName, color, icon); + toast.success(t("hosts.folderAppearanceUpdated")); + await fetchFolderMetadata(); + window.dispatchEvent(new CustomEvent("folders:changed")); + } catch (error) { + console.error("Failed to update folder appearance:", error); + toast.error(t("hosts.failedToUpdateFolderAppearance")); + } + }; + + const handleDeleteAllHostsInFolder = async (folderName: string) => { + const hostsInFolder = hostsByFolder[folderName] || []; + confirmWithToast( + t("hosts.confirmDeleteAllHostsInFolder", { + folder: folderName, + count: hostsInFolder.length, + }), + async () => { + try { + const result = await deleteAllHostsInFolder(folderName); + toast.success( + t("hosts.allHostsInFolderDeleted", { + folder: folderName, + count: result.deletedCount, + }) + ); + await fetchHosts(); + await fetchFolderMetadata(); + window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); + + const { refreshServerPolling } = await import("@/ui/main-axios.ts"); + refreshServerPolling(); + } catch (error) { + console.error("Failed to delete hosts in folder:", error); + toast.error(t("hosts.failedToDeleteHostsInFolder")); + } + }, + "destructive", + ); + }; + + const getFolderIcon = (folderName: string) => { + const metadata = folderMetadata.get(folderName); + if (!metadata?.icon) return Folder; + + const iconMap: Record = { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + }; + + return iconMap[metadata.icon] || Folder; + }; + + const getFolderColor = (folderName: string) => { + const metadata = folderMetadata.get(folderName); + return metadata?.color; + }; + const handleDelete = async (hostId: number, hostName: string) => { confirmWithToast( t("hosts.confirmDelete", { name: hostName }), @@ -854,7 +954,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
- + {(() => { + const FolderIcon = getFolderIcon(folder); + const folderColor = getFolderColor(folder); + return ( + + ); + })()} {editingFolder === folder ? (
{folderHosts.length} + {folder !== t("hosts.uncategorized") && ( +
+ + + + + + + {t("hosts.editFolderAppearance")} + + + + + + + + + + {t("hosts.deleteAllHostsInFolder")} + + + +
+ )}
@@ -1202,6 +1355,22 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ))}
+ + {editingFolderAppearance && ( + { + if (!open) setEditingFolderAppearance(null); + }} + onSave={async (color, icon) => { + await handleSaveFolderAppearance(editingFolderAppearance, color, icon); + setEditingFolderAppearance(null); + }} + /> + )} ); } diff --git a/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx new file mode 100644 index 00000000..d60eabcf --- /dev/null +++ b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useTranslation } from "react-i18next"; +import { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, +} from "lucide-react"; + +interface FolderEditDialogProps { + folderName: string; + currentColor?: string; + currentIcon?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (color: string, icon: string) => Promise; +} + +const AVAILABLE_COLORS = [ + { value: "#ef4444", label: "Red" }, + { value: "#f97316", label: "Orange" }, + { value: "#eab308", label: "Yellow" }, + { value: "#22c55e", label: "Green" }, + { value: "#3b82f6", label: "Blue" }, + { value: "#a855f7", label: "Purple" }, + { value: "#ec4899", label: "Pink" }, + { value: "#6b7280", label: "Gray" }, +]; + +const AVAILABLE_ICONS = [ + { value: "Folder", label: "Folder", Icon: Folder }, + { value: "Server", label: "Server", Icon: Server }, + { value: "Cloud", label: "Cloud", Icon: Cloud }, + { value: "Database", label: "Database", Icon: Database }, + { value: "Box", label: "Box", Icon: Box }, + { value: "Package", label: "Package", Icon: Package }, + { value: "Layers", label: "Layers", Icon: Layers }, + { value: "Archive", label: "Archive", Icon: Archive }, + { value: "HardDrive", label: "HardDrive", Icon: HardDrive }, + { value: "Globe", label: "Globe", Icon: Globe }, +]; + +export function FolderEditDialog({ + folderName, + currentColor, + currentIcon, + open, + onOpenChange, + onSave, +}: FolderEditDialogProps) { + const { t } = useTranslation(); + const [selectedColor, setSelectedColor] = useState(currentColor || AVAILABLE_COLORS[0].value); + const [selectedIcon, setSelectedIcon] = useState(currentIcon || AVAILABLE_ICONS[0].value); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setSelectedColor(currentColor || AVAILABLE_COLORS[0].value); + setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value); + } + }, [open, currentColor, currentIcon]); + + const handleSave = async () => { + setLoading(true); + try { + await onSave(selectedColor, selectedIcon); + onOpenChange(false); + } catch (error) { + console.error("Failed to save folder metadata:", error); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + {t("hosts.editFolderAppearance")} + + + {t("hosts.editFolderAppearanceDesc")}: {folderName} + + + +
+ {/* Color Selection */} +
+ +
+ {AVAILABLE_COLORS.map((color) => ( +
+
+ + {/* Icon Selection */} +
+ +
+ {AVAILABLE_ICONS.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {/* Preview */} +
+ +
+ {(() => { + const IconComponent = AVAILABLE_ICONS.find( + (i) => i.value === selectedIcon + )?.Icon || Folder; + return ( + + ); + })()} + {folderName} +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 4c147071..7d9e6342 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios"; import type { SSHHost, SSHHostData, + SSHFolder, TunnelConfig, TunnelStatus, FileManagerFile, @@ -2451,6 +2452,90 @@ export async function renameFolder( } } +export async function getSSHFolders(): Promise { + try { + sshLogger.info("Fetching SSH folders", { + operation: "fetch_ssh_folders", + }); + + const response = await authApi.get("/ssh/folders"); + + sshLogger.success("SSH folders fetched successfully", { + operation: "fetch_ssh_folders", + count: response.data.length, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to fetch SSH folders", error, { + operation: "fetch_ssh_folders", + }); + handleApiError(error, "fetch SSH folders"); + throw error; + } +} + +export async function updateFolderMetadata( + name: string, + color?: string, + icon?: string, +): Promise { + try { + sshLogger.info("Updating folder metadata", { + operation: "update_folder_metadata", + name, + color, + icon, + }); + + await authApi.put("/ssh/folders/metadata", { + name, + color, + icon, + }); + + sshLogger.success("Folder metadata updated successfully", { + operation: "update_folder_metadata", + name, + }); + } catch (error) { + sshLogger.error("Failed to update folder metadata", error, { + operation: "update_folder_metadata", + name, + }); + handleApiError(error, "update folder metadata"); + throw error; + } +} + +export async function deleteAllHostsInFolder( + folderName: string, +): Promise<{ deletedCount: number }> { + try { + sshLogger.info("Deleting all hosts in folder", { + operation: "delete_folder_hosts", + folderName, + }); + + const response = await authApi.delete(`/ssh/folders/${encodeURIComponent(folderName)}/hosts`); + + sshLogger.success("All hosts in folder deleted successfully", { + operation: "delete_folder_hosts", + folderName, + deletedCount: response.data.deletedCount, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to delete hosts in folder", error, { + operation: "delete_folder_hosts", + folderName, + }); + handleApiError(error, "delete hosts in folder"); + throw error; + } +} + export async function renameCredentialFolder( oldName: string, newName: string,