Add terminal code snippets feature
This commit is contained in:
@@ -6,6 +6,7 @@ import userRoutes from "./routes/users.js";
|
|||||||
import sshRoutes from "./routes/ssh.js";
|
import sshRoutes from "./routes/ssh.js";
|
||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -1411,6 +1412,7 @@ app.use("/users", userRoutes);
|
|||||||
app.use("/ssh", sshRoutes);
|
app.use("/ssh", sshRoutes);
|
||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
|
app.use("/snippets", snippetsRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -172,3 +172,19 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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`),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
|||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
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 {
|
class SimpleDBOps {
|
||||||
static async insert<T extends Record<string, any>>(
|
static async insert<T extends Record<string, any>>(
|
||||||
|
|||||||
@@ -305,6 +305,8 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
"create": "Create",
|
||||||
|
"update": "Update",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
@@ -717,6 +719,35 @@
|
|||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"fileManager": {
|
||||||
"title": "File Manager",
|
"title": "File Manager",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
|
|||||||
@@ -298,6 +298,8 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
|
"create": "创建",
|
||||||
|
"update": "更新",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
@@ -738,6 +740,35 @@
|
|||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"cancel": "取消"
|
"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": {
|
"fileManager": {
|
||||||
"title": "文件管理器",
|
"title": "文件管理器",
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ export interface CredentialData {
|
|||||||
keyType?: string;
|
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
|
// 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
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 {
|
interface SSHTerminalProps {
|
||||||
hostConfig: any;
|
hostConfig: any;
|
||||||
@@ -57,6 +60,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [totpRequired, setTotpRequired] = useState(false);
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
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 isVisibleRef = useRef<boolean>(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttempts = useRef(0);
|
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) {
|
function scheduleNotify(cols: number, rows: number) {
|
||||||
if (!(cols > 0 && rows > 0)) return;
|
if (!(cols > 0 && rows > 0)) return;
|
||||||
pendingSizeRef.current = { cols, rows };
|
pendingSizeRef.current = { cols, rows };
|
||||||
@@ -731,6 +761,19 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative">
|
<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
|
<div
|
||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Snippets Sidebar - absolutely positioned */}
|
||||||
|
<SnippetsSidebar
|
||||||
|
isOpen={snippetsSidebarOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setSnippetsSidebarOpen(false);
|
||||||
|
localStorage.setItem("terminal-sidebar-open", "false");
|
||||||
|
}}
|
||||||
|
onExecute={handleSnippetExecute}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user