Add terminal code snippets feature

This commit is contained in:
ZacharyZcR
2025-10-04 06:36:51 +08:00
parent fb3f5f435b
commit f92cfbfec7
9 changed files with 719 additions and 1 deletions
+2
View File
@@ -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";
@@ -1411,6 +1412,7 @@ app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use(
(
+16
View File
@@ -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`),
});
+260
View File
@@ -0,0 +1,260 @@
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;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet fetch");
return res.status(400).json({ error: "Invalid request" });
}
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;
+1 -1
View File
@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = "users" | "ssh_data" | "ssh_credentials";
type TableName = "users" | "ssh_data" | "ssh_credentials" | "snippets";
class SimpleDBOps {
static async insert<T extends Record<string, any>>(
+31
View File
@@ -305,6 +305,8 @@
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"create": "Create",
"update": "Update",
"search": "Search",
"loading": "Loading...",
"error": "Error",
@@ -717,6 +719,35 @@
"submit": "Submit",
"cancel": "Cancel"
},
"snippets": {
"title": "Snippets",
"new": "New",
"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}}"
},
"fileManager": {
"title": "File Manager",
"file": "File",
+31
View File
@@ -298,6 +298,8 @@
"delete": "删除",
"edit": "编辑",
"add": "添加",
"create": "创建",
"update": "更新",
"search": "搜索",
"confirm": "确认",
"yes": "是",
@@ -738,6 +740,35 @@
"submit": "提交",
"cancel": "取消"
},
"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}}"
},
"fileManager": {
"title": "文件管理器",
"file": "文件",
+20
View File
@@ -97,6 +97,26 @@ export interface CredentialData {
keyType?: string;
}
// ============================================================================
// SNIPPET 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;
}
// ============================================================================
// TUNNEL TYPES
// ============================================================================
@@ -0,0 +1,305 @@
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 { ScrollArea } from "@/components/ui/scroll-area";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Plus, Play, Edit, Trash2, X } from "lucide-react";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
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<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [formData, setFormData] = useState<SnippetData>({
name: "",
content: "",
description: "",
});
const [formErrors, setFormErrors] = useState({
name: false,
content: false,
});
useEffect(() => {
if (isOpen) {
fetchSnippets();
}
}, [isOpen]);
const fetchSnippets = async () => {
try {
setLoading(true);
const response = await axios.get("/snippets");
// Defensive: ensure response.data is an array
setSnippets(Array.isArray(response.data) ? response.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 = async (snippet: Snippet) => {
const confirmed = await confirmWithToast({
title: t("snippets.deleteConfirmTitle"),
description: t("snippets.deleteConfirmDescription", {
name: snippet.name,
}),
});
if (confirmed) {
try {
await axios.delete(`/snippets/${snippet.id}`);
toast.success(t("snippets.deleteSuccess"));
fetchSnippets();
} catch (err) {
toast.error(t("snippets.deleteFailed"));
}
}
};
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 axios.put(`/snippets/${editingSnippet.id}`, formData);
toast.success(t("snippets.updateSuccess"));
} else {
await axios.post("/snippets", 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 }));
};
if (!isOpen) return null;
return (
<>
{/* Sidebar - absolutely positioned, doesn't affect terminal layout */}
<div className="absolute top-0 right-0 h-full w-80 border-l border-border bg-background flex flex-col shadow-lg z-20">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("snippets.title")}</h2>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleCreate}>
<Plus className="w-4 h-4 mr-1" />
{t("snippets.new")}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Snippets List */}
<ScrollArea className="flex-1">
{loading ? (
<div className="p-4 text-center text-muted-foreground">
{t("common.loading")}
</div>
) : snippets.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<p className="mb-2">{t("snippets.empty")}</p>
<p className="text-sm">{t("snippets.emptyHint")}</p>
</div>
) : (
<div className="p-4 space-y-3">
{snippets.map((snippet) => (
<Card key={snippet.id} className="hover:bg-accent/50 transition">
<CardHeader className="p-3 pb-2">
<CardTitle className="text-sm font-medium">
{snippet.name}
</CardTitle>
{snippet.description && (
<CardDescription className="text-xs">
{snippet.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="bg-muted rounded p-2 mb-2">
<code className="text-xs font-mono break-all line-clamp-3">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
>
<Edit className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
{/* Create/Edit Dialog - centered modal */}
{showDialog && (
<div className="fixed inset-0 flex items-center justify-center z-50 bg-black/50 backdrop-blur-sm">
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="mb-4">
<h2 className="text-xl font-semibold text-white">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingSnippet
? t("snippets.editDescription")
: t("snippets.createDescription")}
</p>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t("snippets.name")} <span className="text-destructive">*</span>
</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder={t("snippets.namePlaceholder")}
className={formErrors.name ? "border-destructive" : ""}
/>
{formErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.nameRequired")}
</p>
)}
</div>
<div>
<label className="text-sm font-medium">
{t("snippets.description")}
</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder={t("snippets.descriptionPlaceholder")}
/>
</div>
<div>
<label className="text-sm font-medium">
{t("snippets.content")} <span className="text-destructive">*</span>
</label>
<Textarea
value={formData.content}
onChange={(e) =>
setFormData({ ...formData, content: e.target.value })
}
placeholder={t("snippets.contentPlaceholder")}
className={`font-mono ${formErrors.content ? "border-destructive" : ""}`}
rows={8}
/>
{formErrors.content && (
<p className="text-xs text-destructive mt-1">
{t("snippets.contentRequired")}
</p>
)}
</div>
</div>
<div className="flex gap-3 mt-6">
<Button
variant="outline"
onClick={() => setShowDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} className="flex-1">
{editingSnippet ? t("common.update") : t("common.create")}
</Button>
</div>
</div>
</div>
)}
</>
);
}
+53
View File
@@ -13,6 +13,9 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { SnippetsSidebar } from "./SnippetsSidebar";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
interface SSHTerminalProps {
hostConfig: any;
@@ -57,6 +60,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(() => {
// Load sidebar state from localStorage
const saved = localStorage.getItem("terminal-sidebar-open");
return saved === "true";
});
const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -118,6 +126,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
};
// Handle snippet execution
const handleSnippetExecute = (content: string) => {
if (terminal && webSocketRef.current?.readyState === WebSocket.OPEN) {
// Send command to terminal
webSocketRef.current.send(
JSON.stringify({
type: "input",
data: content + "\n",
}),
);
}
};
// Toggle snippets sidebar and persist state
const toggleSnippetsSidebar = () => {
setSnippetsSidebarOpen((prev) => {
const newState = !prev;
localStorage.setItem("terminal-sidebar-open", String(newState));
return newState;
});
};
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
@@ -731,6 +761,19 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return (
<div className="h-full w-full relative">
{/* Snippets toggle button */}
<div className="absolute top-2 right-2 z-10">
<Button
size="sm"
variant="outline"
onClick={toggleSnippetsSidebar}
className="bg-background/80 backdrop-blur-sm"
>
<FileText className="w-4 h-4" />
</Button>
</div>
{/* Terminal area - full width, not affected by sidebar */}
<div
ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
@@ -806,6 +849,16 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
</div>
</div>
)}
{/* Snippets Sidebar - absolutely positioned */}
<SnippetsSidebar
isOpen={snippetsSidebarOpen}
onClose={() => {
setSnippetsSidebarOpen(false);
localStorage.setItem("terminal-sidebar-open", "false");
}}
onExecute={handleSnippetExecute}
/>
</div>
);
});