diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 48f7aaa0..737d0077 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -6,6 +6,7 @@ import userRoutes from "./routes/users.js"; import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; +import snippetsRoutes from "./routes/snippets.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -30,6 +31,7 @@ import { dismissedAlerts, sshCredentialUsage, settings, + snippets, } from "./db/schema.js"; import { getDb } from "./db/index.js"; import Database from "better-sqlite3"; @@ -1411,6 +1413,7 @@ app.use("/users", userRoutes); app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); +app.use("/snippets", snippetsRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 88cca125..d45fc333 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -242,6 +242,17 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ); + CREATE TABLE IF NOT EXISTS snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + `); migrateSchema(); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index eeac5c34..5957276c 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -172,3 +172,19 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const snippets = sqliteTable("snippets", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + content: text("content").notNull(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts new file mode 100644 index 00000000..23af7bf7 --- /dev/null +++ b/src/backend/database/routes/snippets.ts @@ -0,0 +1,251 @@ +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 type { Request, Response } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +const router = express.Router(); + +function isNonEmptyString(val: any): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Get all snippets for the authenticated user +// GET /snippets +router.get( + "/", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).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(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 any).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 any).userId; + const { name, content, description } = 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 { + const insertData = { + userId, + name: name.trim(), + content: content.trim(), + description: description?.trim() || null, + }; + + 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 any).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: any = { + 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; + + 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 any).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/locales/en/translation.json b/src/locales/en/translation.json index 5e767728..38aedff7 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -191,6 +191,40 @@ "enableRightClickCopyPaste": "Enable right‑click copy/paste", "shareIdeas": "Have ideas for what should come next for ssh tools? Share them on" }, + "snippets": { + "title": "Snippets", + "new": "New Snippet", + "create": "Create Snippet", + "edit": "Edit Snippet", + "run": "Run", + "empty": "No snippets yet", + "emptyHint": "Create a snippet to save commonly used commands", + "name": "Name", + "description": "Description", + "content": "Command", + "namePlaceholder": "e.g., Restart Nginx", + "descriptionPlaceholder": "Optional description", + "contentPlaceholder": "e.g., sudo systemctl restart nginx", + "nameRequired": "Name is required", + "contentRequired": "Command is required", + "createDescription": "Create a new command snippet for quick execution", + "editDescription": "Edit this command snippet", + "deleteConfirmTitle": "Delete Snippet", + "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"?", + "createSuccess": "Snippet created successfully", + "updateSuccess": "Snippet updated successfully", + "deleteSuccess": "Snippet deleted successfully", + "createFailed": "Failed to create snippet", + "updateFailed": "Failed to update snippet", + "deleteFailed": "Failed to delete snippet", + "failedToFetch": "Failed to fetch snippets", + "executeSuccess": "Executing: {{name}}", + "copySuccess": "Copied \"{{name}}\" to clipboard", + "runTooltip": "Execute this snippet in the terminal", + "copyTooltip": "Copy snippet to clipboard", + "editTooltip": "Edit this snippet", + "deleteTooltip": "Delete this snippet" + }, "homepage": { "loggedInTitle": "Logged in!", "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.", @@ -357,6 +391,7 @@ "admin": "Admin", "userProfile": "User Profile", "tools": "Tools", + "snippets": "Snippets", "newTab": "New Tab", "splitScreen": "Split Screen", "closeTab": "Close Tab", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 6a927ab0..3ec02aa5 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -189,6 +189,40 @@ "enableRightClickCopyPaste": "启用右键复制/粘贴", "shareIdeas": "对 SSH 工具有什么想法?在此分享" }, + "snippets": { + "title": "代码片段", + "new": "新建片段", + "create": "创建代码片段", + "edit": "编辑代码片段", + "run": "运行", + "empty": "暂无代码片段", + "emptyHint": "创建代码片段以保存常用命令", + "name": "名称", + "description": "描述", + "content": "命令", + "namePlaceholder": "例如: 重启 Nginx", + "descriptionPlaceholder": "可选描述", + "contentPlaceholder": "例如: sudo systemctl restart nginx", + "nameRequired": "名称不能为空", + "contentRequired": "命令不能为空", + "createDescription": "创建新的命令片段以便快速执行", + "editDescription": "编辑此命令片段", + "deleteConfirmTitle": "删除代码片段", + "deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?", + "createSuccess": "代码片段创建成功", + "updateSuccess": "代码片段更新成功", + "deleteSuccess": "代码片段删除成功", + "createFailed": "创建代码片段失败", + "updateFailed": "更新代码片段失败", + "deleteFailed": "删除代码片段失败", + "failedToFetch": "获取代码片段失败", + "executeSuccess": "正在执行: {{name}}", + "copySuccess": "已复制 \"{{name}}\" 到剪贴板", + "runTooltip": "在终端中执行此片段", + "copyTooltip": "复制片段到剪贴板", + "editTooltip": "编辑此片段", + "deleteTooltip": "删除此片段" + }, "homepage": { "loggedInTitle": "登录成功!", "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。", @@ -343,6 +377,7 @@ "admin": "管理员", "userProfile": "用户资料", "tools": "工具", + "snippets": "代码片段", "newTab": "新标签页", "splitScreen": "分屏", "closeTab": "关闭标签页", diff --git a/src/types/index.ts b/src/types/index.ts index ee7cedb2..e5532893 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -413,6 +413,26 @@ export interface FolderStats { }>; } +// ============================================================================ +// SNIPPETS TYPES +// ============================================================================ + +export interface Snippet { + id: number; + userId: string; + name: string; + content: string; + description?: string; + createdAt: string; + updatedAt: string; +} + +export interface SnippetData { + name: string; + content: string; + description?: string; +} + // ============================================================================ // BACKEND TYPES // ============================================================================ diff --git a/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx new file mode 100644 index 00000000..aff62244 --- /dev/null +++ b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx @@ -0,0 +1,407 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Plus, Play, Edit, Trash2, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { + getSnippets, + createSnippet, + updateSnippet, + deleteSnippet, +} from "@/ui/main-axios"; +import type { Snippet, SnippetData } from "../../../../types/index.js"; + +interface SnippetsSidebarProps { + isOpen: boolean; + onClose: () => void; + onExecute: (content: string) => void; +} + +export function SnippetsSidebar({ + isOpen, + onClose, + onExecute, +}: SnippetsSidebarProps) { + const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); + const [snippets, setSnippets] = useState([]); + const [loading, setLoading] = useState(true); + const [showDialog, setShowDialog] = useState(false); + const [editingSnippet, setEditingSnippet] = useState(null); + const [formData, setFormData] = useState({ + name: "", + content: "", + description: "", + }); + const [formErrors, setFormErrors] = useState({ + name: false, + content: false, + }); + + useEffect(() => { + if (isOpen) { + fetchSnippets(); + } + }, [isOpen]); + + const fetchSnippets = async () => { + try { + setLoading(true); + const data = await getSnippets(); + // Defensive: ensure data is an array + setSnippets(Array.isArray(data) ? data : []); + } catch (err) { + toast.error(t("snippets.failedToFetch")); + setSnippets([]); + } finally { + setLoading(false); + } + }; + + const handleCreate = () => { + setEditingSnippet(null); + setFormData({ name: "", content: "", description: "" }); + setFormErrors({ name: false, content: false }); + setShowDialog(true); + }; + + const handleEdit = (snippet: Snippet) => { + setEditingSnippet(snippet); + setFormData({ + name: snippet.name, + content: snippet.content, + description: snippet.description || "", + }); + setFormErrors({ name: false, content: false }); + setShowDialog(true); + }; + + const handleDelete = (snippet: Snippet) => { + confirmWithToast( + t("snippets.deleteConfirmDescription", { name: snippet.name }), + async () => { + try { + await deleteSnippet(snippet.id); + toast.success(t("snippets.deleteSuccess")); + fetchSnippets(); + } catch (err) { + toast.error(t("snippets.deleteFailed")); + } + }, + "destructive", + ); + }; + + const handleSubmit = async () => { + // Validate required fields + const errors = { + name: !formData.name.trim(), + content: !formData.content.trim(), + }; + + setFormErrors(errors); + + if (errors.name || errors.content) { + return; + } + + try { + if (editingSnippet) { + await updateSnippet(editingSnippet.id, formData); + toast.success(t("snippets.updateSuccess")); + } else { + await createSnippet(formData); + toast.success(t("snippets.createSuccess")); + } + setShowDialog(false); + fetchSnippets(); + } catch (err) { + toast.error( + editingSnippet + ? t("snippets.updateFailed") + : t("snippets.createFailed"), + ); + } + }; + + const handleExecute = (snippet: Snippet) => { + onExecute(snippet.content); + toast.success(t("snippets.executeSuccess", { name: snippet.name })); + }; + + const handleCopy = (snippet: Snippet) => { + navigator.clipboard.writeText(snippet.content); + toast.success(t("snippets.copySuccess", { name: snippet.name })); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Overlay and Sidebar */} +
+
+ +
e.stopPropagation()} + > + {/* Header */} +
+

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

+ +
+ + {/* Content */} +
+
+ + + {loading ? ( +
+

{t("common.loading")}

+
+ ) : snippets.length === 0 ? ( +
+

{t("snippets.empty")}

+

{t("snippets.emptyHint")}

+
+ ) : ( + +
+ {snippets.map((snippet) => ( +
+
+

+ {snippet.name} +

+ {snippet.description && ( +

+ {snippet.description} +

+ )} +
+ +
+ + {snippet.content} + +
+ +
+ + + + + +

{t("snippets.runTooltip")}

+
+
+ + + + + + +

{t("snippets.copyTooltip")}

+
+
+ + + + + + +

{t("snippets.editTooltip")}

+
+
+ + + + + + +

{t("snippets.deleteTooltip")}

+
+
+
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* Create/Edit Dialog - centered modal */} + {showDialog && ( +
setShowDialog(false)} + > +
e.stopPropagation()} + > +
+

+ {editingSnippet ? t("snippets.edit") : t("snippets.create")} +

+

+ {editingSnippet + ? t("snippets.editDescription") + : t("snippets.createDescription")} +

+
+ +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder={t("snippets.namePlaceholder")} + className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`} + autoFocus + /> + {formErrors.name && ( +

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

+ )} +
+ +
+ + + setFormData({ ...formData, description: e.target.value }) + } + placeholder={t("snippets.descriptionPlaceholder")} + /> +
+ +
+ +