diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5ff7c5d0..f9e1017f 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -386,14 +386,14 @@ const addColumnIfNotExists = ( try { sqlite .prepare( - `SELECT ${column} + `SELECT "${column}" FROM ${table} LIMIT 1`, ) .get(); } catch { try { sqlite.exec(`ALTER TABLE ${table} - ADD COLUMN ${column} ${definition};`); + ADD COLUMN "${column}" ${definition};`); } catch (alterError) { databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: "schema_migration", @@ -495,6 +495,35 @@ const migrateSchema = () => { addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); + addColumnIfNotExists("snippets", "folder", "TEXT"); + addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0"); + + try { + sqlite + .prepare("SELECT id FROM snippet_folders LIMIT 1") + .get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS snippet_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create snippet_folders table", { + operation: "schema_migration", + error: createError, + }); + } + } + try { sqlite .prepare("SELECT id FROM sessions LIMIT 1") diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b8676b5f..074b4103 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -206,6 +206,24 @@ export const snippets = sqliteTable("snippets", { name: text("name").notNull(), content: text("content").notNull(), description: text("description"), + folder: text("folder"), + order: integer("order").notNull().default(0), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippetFolders = sqliteTable("snippet_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 079ffdac..51001350 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -1,8 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; -import { snippets } from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; +import { snippets, snippetFolders } from "../db/schema.js"; +import { eq, and, desc, asc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -17,241 +17,389 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// Get all snippets for the authenticated user -// GET /snippets +// Get all snippet folders +// GET /snippets/folders router.get( - "/", + "/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { - authLogger.warn("Invalid userId for snippets fetch"); + authLogger.warn("Invalid userId for snippet folders fetch"); return res.status(400).json({ error: "Invalid userId" }); } try { const result = await db .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy(desc(snippets.updatedAt)); + .from(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .orderBy(asc(snippetFolders.name)); res.json(result); } catch (err) { - authLogger.error("Failed to fetch snippets", err); - res.status(500).json({ error: "Failed to fetch snippets" }); + authLogger.error("Failed to fetch snippet folders", err); + res.status(500).json({ error: "Failed to fetch snippet folders" }); } }, ); -// Get a specific snippet by ID -// GET /snippets/:id -router.get( - "/:id", - authenticateJWT, - requireDataAccess, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const { id } = req.params; - const snippetId = parseInt(id, 10); - - if (!isNonEmptyString(userId) || isNaN(snippetId)) { - authLogger.warn("Invalid request for snippet fetch: invalid ID", { - userId, - id, - }); - return res.status(400).json({ error: "Invalid request parameters" }); - } - - try { - const result = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); - - if (result.length === 0) { - return res.status(404).json({ error: "Snippet not found" }); - } - - res.json(result[0]); - } catch (err) { - authLogger.error("Failed to fetch snippet", err); - res.status(500).json({ - error: err instanceof Error ? err.message : "Failed to fetch snippet", - }); - } - }, -); - -// Create a new snippet -// POST /snippets +// Create a new snippet folder +// POST /snippets/folders router.post( - "/", + "/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { name, content, description } = req.body; + const { name, color, icon } = req.body; - if ( - !isNonEmptyString(userId) || - !isNonEmptyString(name) || - !isNonEmptyString(content) - ) { - authLogger.warn("Invalid snippet creation data validation failed", { - operation: "snippet_create", + if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { + authLogger.warn("Invalid snippet folder creation data", { + operation: "snippet_folder_create", userId, hasName: !!name, - hasContent: !!content, }); - return res.status(400).json({ error: "Name and content are required" }); + return res.status(400).json({ error: "Folder name is required" }); } try { + const existing = await db + .select() + .from(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); + + if (existing.length > 0) { + return res + .status(409) + .json({ error: "Folder with this name already exists" }); + } + const insertData = { userId, name: name.trim(), - content: content.trim(), - description: description?.trim() || null, + color: color?.trim() || null, + icon: icon?.trim() || null, }; - const result = await db.insert(snippets).values(insertData).returning(); + const result = await db + .insert(snippetFolders) + .values(insertData) + .returning(); - authLogger.success(`Snippet created: ${name} by user ${userId}`, { - operation: "snippet_create_success", + authLogger.success(`Snippet folder created: ${name} by user ${userId}`, { + operation: "snippet_folder_create_success", userId, - snippetId: result[0].id, name, }); res.status(201).json(result[0]); } catch (err) { - authLogger.error("Failed to create snippet", err); + authLogger.error("Failed to create snippet folder", err); res.status(500).json({ - error: err instanceof Error ? err.message : "Failed to create snippet", + error: + err instanceof Error + ? err.message + : "Failed to create snippet folder", }); } }, ); -// Update a snippet -// PUT /snippets/:id +// Update snippet folder metadata (color, icon) +// PUT /snippets/folders/:name/metadata router.put( - "/:id", + "/folders/:name/metadata", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { id } = req.params; - const updateData = req.body; + const { name } = req.params; + const { color, icon } = req.body; - if (!isNonEmptyString(userId) || !id) { - authLogger.warn("Invalid request for snippet update"); + if (!isNonEmptyString(userId) || !name) { + authLogger.warn("Invalid request for snippet folder metadata update"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); if (existing.length === 0) { - return res.status(404).json({ error: "Snippet not found" }); + return res.status(404).json({ error: "Folder not found" }); } const updateFields: Partial<{ + color: string | null; + icon: string | null; updatedAt: ReturnType; - name: string; - content: string; - description: string | null; }> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; - if (updateData.name !== undefined) - updateFields.name = updateData.name.trim(); - if (updateData.content !== undefined) - updateFields.content = updateData.content.trim(); - if (updateData.description !== undefined) - updateFields.description = updateData.description?.trim() || null; + if (color !== undefined) updateFields.color = color?.trim() || null; + if (icon !== undefined) updateFields.icon = icon?.trim() || null; await db - .update(snippets) + .update(snippetFolders) .set(updateFields) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); const updated = await db .select() - .from(snippets) - .where(eq(snippets.id, parseInt(id))); + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); authLogger.success( - `Snippet updated: ${updated[0].name} by user ${userId}`, + `Snippet folder metadata updated: ${name} by user ${userId}`, { - operation: "snippet_update_success", + operation: "snippet_folder_metadata_update_success", userId, - snippetId: parseInt(id), - name: updated[0].name, + name, }, ); res.json(updated[0]); } catch (err) { - authLogger.error("Failed to update snippet", err); + authLogger.error("Failed to update snippet folder metadata", err); res.status(500).json({ - error: err instanceof Error ? err.message : "Failed to update snippet", + error: + err instanceof Error + ? err.message + : "Failed to update snippet folder metadata", }); } }, ); -// Delete a snippet -// DELETE /snippets/:id -router.delete( - "/:id", +// Rename snippet folder +// PUT /snippets/folders/rename +router.put( + "/folders/rename", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { id } = req.params; + const { oldName, newName } = req.body; - if (!isNonEmptyString(userId) || !id) { - authLogger.warn("Invalid request for snippet delete"); + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(oldName) || + !isNonEmptyString(newName) + ) { + authLogger.warn("Invalid request for snippet folder rename"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); if (existing.length === 0) { - return res.status(404).json({ error: "Snippet not found" }); + return res.status(404).json({ error: "Folder not found" }); + } + + const nameExists = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, newName), + ), + ); + + if (nameExists.length > 0) { + return res + .status(409) + .json({ error: "Folder with new name already exists" }); } await db - .delete(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + .update(snippetFolders) + .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); + + await db + .update(snippets) + .set({ folder: newName }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); authLogger.success( - `Snippet deleted: ${existing[0].name} by user ${userId}`, + `Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`, { - operation: "snippet_delete_success", + operation: "snippet_folder_rename_success", userId, - snippetId: parseInt(id), - name: existing[0].name, + oldName, + newName, + }, + ); + + res.json({ success: true, oldName, newName }); + } catch (err) { + authLogger.error("Failed to rename snippet folder", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to rename snippet folder", + }); + } + }, +); + +// Delete snippet folder +// DELETE /snippets/folders/:name +router.delete( + "/folders/:name", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name } = req.params; + + if (!isNonEmptyString(userId) || !name) { + authLogger.warn("Invalid request for snippet folder delete"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const folderName = decodeURIComponent(name); + + await db + .update(snippets) + .set({ folder: null }) + .where( + and(eq(snippets.userId, userId), eq(snippets.folder, folderName)), + ); + + await db + .delete(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, folderName), + ), + ); + + authLogger.success( + `Snippet folder deleted: ${folderName} by user ${userId}`, + { + operation: "snippet_folder_delete_success", + userId, + name: folderName, }, ); res.json({ success: true }); } catch (err) { - authLogger.error("Failed to delete snippet", err); + authLogger.error("Failed to delete snippet folder", err); res.status(500).json({ - error: err instanceof Error ? err.message : "Failed to delete snippet", + error: + err instanceof Error + ? err.message + : "Failed to delete snippet folder", + }); + } + }, +); + +// Reorder snippets (bulk update) +// PUT /snippets/reorder +router.put( + "/reorder", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { snippets: snippetUpdates } = req.body; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippet reorder"); + return res.status(400).json({ error: "Invalid userId" }); + } + + if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) { + authLogger.warn("Invalid snippet reorder data", { + operation: "snippet_reorder", + userId, + }); + return res + .status(400) + .json({ error: "snippets array is required and must not be empty" }); + } + + try { + for (const update of snippetUpdates) { + const { id, order, folder } = update; + + if (!id || order === undefined) { + continue; + } + + const updateFields: Partial<{ + order: number; + folder: string | null; + }> = { + order, + }; + + if (folder !== undefined) { + updateFields.folder = folder?.trim() || null; + } + + await db + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); + } + + authLogger.success(`Snippets reordered by user ${userId}`, { + operation: "snippet_reorder_success", + userId, + count: snippetUpdates.length, + }); + + res.json({ success: true, updated: snippetUpdates.length }); + } catch (err) { + authLogger.error("Failed to reorder snippets", err); + res.status(500).json({ + error: + err instanceof Error ? err.message : "Failed to reorder snippets", }); } }, @@ -514,4 +662,274 @@ router.post( }, ); +// Get all snippets for the authenticated user +// GET /snippets +router.get( + "/", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippets fetch"); + return res.status(400).json({ error: "Invalid userId" }); + } + + try { + const result = await db + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy( + sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, + asc(snippets.folder), + asc(snippets.order), + desc(snippets.updatedAt), + ); + + res.json(result); + } catch (err) { + authLogger.error("Failed to fetch snippets", err); + res.status(500).json({ error: "Failed to fetch snippets" }); + } + }, +); + +// Get a specific snippet by ID +// GET /snippets/:id +router.get( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { id } = req.params; + const snippetId = parseInt(id, 10); + + if (!isNonEmptyString(userId) || isNaN(snippetId)) { + authLogger.warn("Invalid request for snippet fetch: invalid ID", { + userId, + id, + }); + return res.status(400).json({ error: "Invalid request parameters" }); + } + + try { + const result = await db + .select() + .from(snippets) + .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + + if (result.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + res.json(result[0]); + } catch (err) { + authLogger.error("Failed to fetch snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch snippet", + }); + } + }, +); + +// Create a new snippet +// POST /snippets +router.post( + "/", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, content, description, folder, order } = req.body; + + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(name) || + !isNonEmptyString(content) + ) { + authLogger.warn("Invalid snippet creation data validation failed", { + operation: "snippet_create", + userId, + hasName: !!name, + hasContent: !!content, + }); + return res.status(400).json({ error: "Name and content are required" }); + } + + try { + let snippetOrder = order; + if (snippetOrder === undefined || snippetOrder === null) { + const folderValue = folder?.trim() || ""; + const maxOrderResult = await db + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folderValue + ? eq(snippets.folder, folderValue) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; + snippetOrder = maxOrder + 1; + } + + const insertData = { + userId, + name: name.trim(), + content: content.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + order: snippetOrder, + }; + + const result = await db.insert(snippets).values(insertData).returning(); + + authLogger.success(`Snippet created: ${name} by user ${userId}`, { + operation: "snippet_create_success", + userId, + snippetId: result[0].id, + name, + }); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to create snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to create snippet", + }); + } + }, +); + +// Update a snippet +// PUT /snippets/:id +router.put( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { id } = req.params; + const updateData = req.body; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for snippet update"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippets) + .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + + if (existing.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + const updateFields: Partial<{ + updatedAt: ReturnType; + name: string; + content: string; + description: string | null; + folder: string | null; + order: number; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (updateData.name !== undefined) + updateFields.name = updateData.name.trim(); + if (updateData.content !== undefined) + updateFields.content = updateData.content.trim(); + if (updateData.description !== undefined) + updateFields.description = updateData.description?.trim() || null; + if (updateData.folder !== undefined) + updateFields.folder = updateData.folder?.trim() || null; + if (updateData.order !== undefined) updateFields.order = updateData.order; + + await db + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + + const updated = await db + .select() + .from(snippets) + .where(eq(snippets.id, parseInt(id))); + + authLogger.success( + `Snippet updated: ${updated[0].name} by user ${userId}`, + { + operation: "snippet_update_success", + userId, + snippetId: parseInt(id), + name: updated[0].name, + }, + ); + + res.json(updated[0]); + } catch (err) { + authLogger.error("Failed to update snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to update snippet", + }); + } + }, +); + +// Delete a snippet +// DELETE /snippets/:id +router.delete( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { id } = req.params; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for snippet delete"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippets) + .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + + if (existing.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + await db + .delete(snippets) + .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + + authLogger.success( + `Snippet deleted: ${existing[0].name} by user ${userId}`, + { + operation: "snippet_delete_success", + userId, + snippetId: parseInt(id), + name: existing[0].name, + }, + ); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete snippet", + }); + } + }, +); + export default router; diff --git a/src/types/index.ts b/src/types/index.ts index c20ffa05..7adc1ab6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -506,6 +506,8 @@ export interface Snippet { name: string; content: string; description?: string; + folder?: string; + order?: number; createdAt: string; updatedAt: string; } @@ -514,6 +516,18 @@ export interface SnippetData { name: string; content: string; description?: string; + folder?: string; + order?: number; +} + +export interface SnippetFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx index c8b0e5d2..9ecd5841 100644 --- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx +++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx @@ -4,6 +4,14 @@ import { Input } from "@/components/ui/input.tsx"; import { Textarea } from "@/components/ui/textarea.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { Checkbox } from "@/components/ui/checkbox.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Label } from "@/components/ui/label.tsx"; import { Tabs, TabsList, @@ -36,6 +44,22 @@ import { Terminal, LayoutGrid, MonitorCheck, + Folder, + ChevronDown, + ChevronRight, + GripVertical, + FolderPlus, + Settings, + MoreVertical, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -49,9 +73,15 @@ import { setCookie, getCommandHistory, deleteCommandFromHistory, + getSnippetFolders, + createSnippetFolder, + updateSnippetFolderMetadata, + renameSnippetFolder, + deleteSnippetFolder, + reorderSnippets, } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; -import type { Snippet, SnippetData } from "../../../../types"; +import type { Snippet, SnippetData, SnippetFolder } from "../../../../types"; interface TabData { id: number; @@ -79,6 +109,30 @@ interface SSHToolsSidebarProps { onTabChange?: () => void; } +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 SSHToolsSidebar({ isOpen, onClose, @@ -125,6 +179,7 @@ export function SSHToolsSidebar({ ); const [snippets, setSnippets] = useState([]); + const [snippetFolders, setSnippetFolders] = useState([]); const [loading, setLoading] = useState(true); const [showDialog, setShowDialog] = useState(false); const [editingSnippet, setEditingSnippet] = useState(null); @@ -140,6 +195,23 @@ export function SSHToolsSidebar({ const [selectedSnippetTabIds, setSelectedSnippetTabIds] = useState( [], ); + const [draggedSnippet, setDraggedSnippet] = useState(null); + const [dragOverFolder, setDragOverFolder] = useState(null); + const [collapsedFolders, setCollapsedFolders] = useState>( + new Set(), + ); + const [showFolderDialog, setShowFolderDialog] = useState(false); + const [editingFolder, setEditingFolder] = useState( + null, + ); + const [folderFormData, setFolderFormData] = useState({ + name: "", + color: "", + icon: "", + }); + const [folderFormErrors, setFolderFormErrors] = useState({ + name: false, + }); const [commandHistory, setCommandHistory] = useState([]); const [isHistoryLoading, setIsHistoryLoading] = useState(false); @@ -470,11 +542,16 @@ export function SSHToolsSidebar({ const fetchSnippets = async () => { try { setLoading(true); - const data = await getSnippets(); - setSnippets(Array.isArray(data) ? data : []); + const [snippetsData, foldersData] = await Promise.all([ + getSnippets(), + getSnippetFolders(), + ]); + setSnippets(Array.isArray(snippetsData) ? snippetsData : []); + setSnippetFolders(Array.isArray(foldersData) ? foldersData : []); } catch { toast.error(t("snippets.failedToFetch")); setSnippets([]); + setSnippetFolders([]); } finally { setLoading(false); } @@ -493,6 +570,7 @@ export function SSHToolsSidebar({ name: snippet.name, content: snippet.content, description: snippet.description || "", + folder: snippet.folder, }); setFormErrors({ name: false, content: false }); setShowDialog(true); @@ -578,6 +656,246 @@ export function SSHToolsSidebar({ toast.success(t("snippets.copySuccess", { name: snippet.name })); }; + const toggleFolder = (folderName: string) => { + setCollapsedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderName)) { + next.delete(folderName); + } else { + next.add(folderName); + } + return next; + }); + }; + + const getFolderIcon = (folderName: string) => { + const metadata = snippetFolders.find((f) => f.name === folderName); + if (!metadata?.icon) return Folder; + + const iconData = AVAILABLE_ICONS.find((i) => i.value === metadata.icon); + return iconData?.Icon || Folder; + }; + + const getFolderColor = (folderName: string) => { + const metadata = snippetFolders.find((f) => f.name === folderName); + return metadata?.color; + }; + + const groupSnippetsByFolder = () => { + const grouped = new Map(); + + snippetFolders.forEach((folder) => { + if (!grouped.has(folder.name)) { + grouped.set(folder.name, []); + } + }); + + snippets.forEach((snippet) => { + const folderName = snippet.folder || ""; + if (!grouped.has(folderName)) { + grouped.set(folderName, []); + } + grouped.get(folderName)!.push(snippet); + }); + + return grouped; + }; + + const handleDragStart = (e: React.DragEvent, snippet: Snippet) => { + setDraggedSnippet(snippet); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleDragOver = (e: React.DragEvent, targetSnippet: Snippet) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDragEnterFolder = (folderName: string) => { + setDragOverFolder(folderName); + }; + + const handleDragLeaveFolder = () => { + setDragOverFolder(null); + }; + + const handleDrop = async (e: React.DragEvent, targetSnippet: Snippet) => { + e.preventDefault(); + + if (!draggedSnippet || draggedSnippet.id === targetSnippet.id) { + setDraggedSnippet(null); + setDragOverFolder(null); + return; + } + + const sourceFolder = draggedSnippet.folder || ""; + const targetFolder = targetSnippet.folder || ""; + + if (sourceFolder !== targetFolder) { + toast.error( + t("snippets.reorderSameFolder", { + defaultValue: "Can only reorder snippets within the same folder", + }), + ); + setDraggedSnippet(null); + setDragOverFolder(null); + return; + } + + const folderSnippets = snippets.filter( + (s) => (s.folder || "") === targetFolder, + ); + + const draggedIndex = folderSnippets.findIndex( + (s) => s.id === draggedSnippet.id, + ); + const targetIndex = folderSnippets.findIndex( + (s) => s.id === targetSnippet.id, + ); + + if (draggedIndex === -1 || targetIndex === -1) { + setDraggedSnippet(null); + setDragOverFolder(null); + return; + } + + const reorderedSnippets = [...folderSnippets]; + reorderedSnippets.splice(draggedIndex, 1); + reorderedSnippets.splice(targetIndex, 0, draggedSnippet); + + const updates = reorderedSnippets.map((snippet, index) => ({ + id: snippet.id, + order: index, + folder: targetFolder || undefined, + })); + + try { + await reorderSnippets(updates); + toast.success( + t("snippets.reorderSuccess", { + defaultValue: "Snippets reordered successfully", + }), + ); + fetchSnippets(); + } catch { + toast.error( + t("snippets.reorderFailed", { + defaultValue: "Failed to reorder snippets", + }), + ); + } + + setDraggedSnippet(null); + setDragOverFolder(null); + }; + + const handleDragEnd = () => { + setDraggedSnippet(null); + setDragOverFolder(null); + }; + + const handleCreateFolder = () => { + setEditingFolder(null); + setFolderFormData({ + name: "", + color: AVAILABLE_COLORS[0].value, + icon: AVAILABLE_ICONS[0].value, + }); + setFolderFormErrors({ name: false }); + setShowFolderDialog(true); + }; + + const handleEditFolder = (folder: SnippetFolder) => { + setEditingFolder(folder); + setFolderFormData({ + name: folder.name, + color: folder.color || AVAILABLE_COLORS[0].value, + icon: folder.icon || AVAILABLE_ICONS[0].value, + }); + setFolderFormErrors({ name: false }); + setShowFolderDialog(true); + }; + + const handleDeleteFolder = (folderName: string) => { + confirmWithToast( + t("snippets.deleteFolderConfirm", { + name: folderName, + defaultValue: `Delete folder "${folderName}"? All snippets will be moved to Uncategorized.`, + }), + async () => { + try { + await deleteSnippetFolder(folderName); + toast.success( + t("snippets.deleteFolderSuccess", { + defaultValue: "Folder deleted successfully", + }), + ); + fetchSnippets(); + } catch { + toast.error( + t("snippets.deleteFolderFailed", { + defaultValue: "Failed to delete folder", + }), + ); + } + }, + "destructive", + ); + }; + + const handleFolderSubmit = async () => { + const errors = { + name: !folderFormData.name.trim(), + }; + + setFolderFormErrors(errors); + + if (errors.name) { + return; + } + + try { + if (editingFolder) { + if (editingFolder.name !== folderFormData.name) { + await renameSnippetFolder(editingFolder.name, folderFormData.name); + } + await updateSnippetFolderMetadata(folderFormData.name, { + color: folderFormData.color || undefined, + icon: folderFormData.icon || undefined, + }); + toast.success( + t("snippets.updateFolderSuccess", { + defaultValue: "Folder updated successfully", + }), + ); + } else { + await createSnippetFolder({ + name: folderFormData.name, + color: folderFormData.color || undefined, + icon: folderFormData.icon || undefined, + }); + toast.success( + t("snippets.createFolderSuccess", { + defaultValue: "Folder created successfully", + }), + ); + } + + setShowFolderDialog(false); + fetchSnippets(); + } catch { + toast.error( + editingFolder + ? t("snippets.updateFolderFailed", { + defaultValue: "Failed to update folder", + }) + : t("snippets.createFolderFailed", { + defaultValue: "Failed to create folder", + }), + ); + } + }; + const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => { setSplitMode(mode); @@ -589,25 +907,25 @@ export function SSHToolsSidebar({ } }; - const handleDragStart = (tabId: number) => { + const handleTabDragStart = (tabId: number) => { setDraggedTabId(tabId); }; - const handleDragEnd = () => { + const handleTabDragEnd = () => { setDraggedTabId(null); setDragOverCellIndex(null); }; - const handleDragOver = (e: React.DragEvent, cellIndex: number) => { + const handleTabDragOver = (e: React.DragEvent, cellIndex: number) => { e.preventDefault(); setDragOverCellIndex(cellIndex); }; - const handleDragLeave = () => { + const handleTabDragLeave = () => { setDragOverCellIndex(null); }; - const handleDrop = (cellIndex: number) => { + const handleTabDrop = (cellIndex: number) => { if (draggedTabId === null) return; setSplitAssignments((prev) => { @@ -893,64 +1211,81 @@ export function SSHToolsSidebar({ - - {terminalTabs.length > 0 && ( - <> -
- -

- {selectedSnippetTabIds.length > 0 - ? t("snippets.executeOnSelected", { - defaultValue: `Execute on ${selectedSnippetTabIds.length} selected terminal(s)`, - count: selectedSnippetTabIds.length, - }) - : t("snippets.executeOnCurrent", { - defaultValue: - "Execute on current terminal (click to select multiple)", - })} -

-
- {terminalTabs.map((tab) => ( - - ))} + +
+ {terminalTabs.length > 0 && ( + <> +
+ +

+ {selectedSnippetTabIds.length > 0 + ? t("snippets.executeOnSelected", { + defaultValue: `Execute on ${selectedSnippetTabIds.length} selected terminal(s)`, + count: selectedSnippetTabIds.length, + }) + : t("snippets.executeOnCurrent", { + defaultValue: + "Execute on current terminal (click to select multiple)", + })} +

+
+ {terminalTabs.map((tab) => ( + + ))} +
-
- - - )} + + + )} - +
+ + +
+
{loading ? ( -
+

{t("common.loading")}

- ) : snippets.length === 0 ? ( -
+ ) : snippets.length === 0 && snippetFolders.length === 0 ? ( +

{t("snippets.empty")}

@@ -958,98 +1293,227 @@ export function SSHToolsSidebar({
) : ( -
- {snippets.map((snippet) => ( -
-
-

- {snippet.name} -

- {snippet.description && ( -

- {snippet.description} -

- )} -

- ID: {snippet.id} -

-
+
+ {Array.from(groupSnippetsByFolder()).map( + ([folderName, folderSnippets]) => { + const folderMetadata = snippetFolders.find( + (f) => f.name === folderName, + ); + const isCollapsed = + collapsedFolders.has(folderName); -
- - {snippet.content} - -
- -
- - - - - -

{t("snippets.runTooltip")}

-
-
+ {isCollapsed ? ( + + ) : ( + + )} + {(() => { + const FolderIcon = + getFolderIcon(folderName); + const folderColor = + getFolderColor(folderName); + return ( + + ); + })()} + + {folderName || + t("snippets.uncategorized", { + defaultValue: "Uncategorized", + })} + + + {folderSnippets.length} + +
+ {folderName && ( +
+ + +
+ )} +
- - - - - -

{t("snippets.copyTooltip")}

-
-
+ {/* Folder Content */} + {!isCollapsed && ( +
+ {folderSnippets.map((snippet) => ( +
+ handleDragStart(e, snippet) + } + onDragOver={(e) => + handleDragOver(e, snippet) + } + onDrop={(e) => handleDrop(e, snippet)} + onDragEnd={handleDragEnd} + className={`bg-dark-bg-input border border-input rounded-lg cursor-move hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group ${ + draggedSnippet?.id === snippet.id + ? "opacity-50" + : "" + }`} + > +
+ +
+

+ {snippet.name} +

+ {snippet.description && ( +

+ {snippet.description} +

+ )} +

+ ID: {snippet.id} +

+
+
- - - - - -

{t("snippets.editTooltip")}

-
-
+
+ + {snippet.content} + +
- - - - - -

{t("snippets.deleteTooltip")}

-
-
-
-
- ))} +
+ + + + + +

+ {t("snippets.runTooltip")} +

+
+
+ + + + + + +

+ {t("snippets.copyTooltip")} +

+
+
+ + + + + + +

+ {t("snippets.editTooltip")} +

+
+
+ + + + + + +

+ {t("snippets.deleteTooltip")} +

+
+
+
+
+ ))} +
+ )} +
+ ); + }, + )}
)} @@ -1246,8 +1710,10 @@ export function SSHToolsSidebar({
handleDragStart(tab.id)} - onDragEnd={handleDragEnd} + onDragStart={() => + handleTabDragStart(tab.id) + } + onDragEnd={handleTabDragEnd} className={` px-3 py-2 rounded-md text-sm cursor-move transition-all ${ @@ -1299,9 +1765,11 @@ export function SSHToolsSidebar({ return (
handleDragOver(e, idx)} - onDragLeave={handleDragLeave} - onDrop={() => handleDrop(idx)} + onDragOver={(e) => + handleTabDragOver(e, idx) + } + onDragLeave={handleTabDragLeave} + onDrop={() => handleTabDrop(idx)} className={` relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px] flex flex-col items-center justify-center transition-all @@ -1482,6 +1950,56 @@ export function SSHToolsSidebar({ />
+
+ + +
+
)} + + {showFolderDialog && ( +
setShowFolderDialog(false)} + > +
e.stopPropagation()} + > +
+

+ {editingFolder + ? t("snippets.editFolder", { defaultValue: "Edit Folder" }) + : t("snippets.createFolder", { + defaultValue: "Create Folder", + })} +

+

+ {editingFolder + ? t("snippets.editFolderDescription", { + defaultValue: "Customize your snippet folder", + }) + : t("snippets.createFolderDescription", { + defaultValue: "Organize your snippets into folders", + })} +

+
+ +
+
+ + + setFolderFormData({ + ...folderFormData, + name: e.target.value, + }) + } + placeholder={t("snippets.folderNamePlaceholder", { + defaultValue: "e.g., System Commands, Docker Scripts", + })} + className={`${folderFormErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`} + autoFocus + /> + {folderFormErrors.name && ( +

+ {t("snippets.folderNameRequired", { + defaultValue: "Folder name is required", + })} +

+ )} +
+ + {/* Color Selection */} +
+ +
+ {AVAILABLE_COLORS.map((color) => ( +
+
+ + {/* Icon Selection */} +
+ +
+ {AVAILABLE_ICONS.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {/* Preview */} +
+ +
+ {(() => { + const IconComponent = + AVAILABLE_ICONS.find( + (i) => i.value === folderFormData.icon, + )?.Icon || Folder; + return ( + + ); + })()} + + {folderFormData.name || + t("snippets.folderName", { defaultValue: "Folder Name" })} + +
+
+
+ + + +
+ + +
+
+
+ )} ); } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 34c477f6..0bd7ee5b 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2874,6 +2874,82 @@ export async function executeSnippet( } } +export async function reorderSnippets( + snippets: Array<{ id: number; order: number; folder?: string }>, +): Promise<{ success: boolean; updated: number }> { + try { + const response = await authApi.put("/snippets/reorder", { snippets }); + return response.data; + } catch (error) { + throw handleApiError(error, "reorder snippets"); + } +} + +export async function getSnippetFolders(): Promise> { + try { + const response = await authApi.get("/snippets/folders"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch snippet folders"); + } +} + +export async function createSnippetFolder(folderData: { + name: string; + color?: string; + icon?: string; +}): Promise> { + try { + const response = await authApi.post("/snippets/folders", folderData); + return response.data; + } catch (error) { + throw handleApiError(error, "create snippet folder"); + } +} + +export async function updateSnippetFolderMetadata( + folderName: string, + metadata: { color?: string; icon?: string }, +): Promise> { + try { + const response = await authApi.put( + `/snippets/folders/${encodeURIComponent(folderName)}/metadata`, + metadata, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "update snippet folder metadata"); + } +} + +export async function renameSnippetFolder( + oldName: string, + newName: string, +): Promise<{ success: boolean; oldName: string; newName: string }> { + try { + const response = await authApi.put("/snippets/folders/rename", { + oldName, + newName, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "rename snippet folder"); + } +} + +export async function deleteSnippetFolder( + folderName: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete( + `/snippets/folders/${encodeURIComponent(folderName)}`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "delete snippet folder"); + } +} + // ============================================================================ // HOMEPAGE API // ============================================================================