Add terminal code snippets feature #341
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
The error handling in the 
The error handling in the `catch` block exposes internal error details (`err.message`) to the client. This can be a security risk, as it might leak information about the database or application structure. It's a best practice to return a generic error message for 500-level errors. This applies to the other handlers in this file as well.
```typescript
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({ error: "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) {
|
||||
|
The current implementation for updating a snippet is inefficient and has a potential security issue. It involves three separate database queries: one to check existence, one to update, and a final one to fetch the updated record. The final This can be simplified, secured, and made more type-safe by using Drizzle's 
The current implementation for updating a snippet is inefficient and has a potential security issue. It involves three separate database queries: one to check existence, one to update, and a final one to fetch the updated record. The final `select` query on line 182 lacks a `userId` filter, which could potentially leak data under specific race conditions. Additionally, `updateFields` is typed as `any`, reducing type safety.
This can be simplified, secured, and made more type-safe by using Drizzle's `returning()` method on the update query. This performs the update and returns the modified record in a single, atomic operation.
```typescript
try {
const updateFields: Partial<typeof snippets.$inferInsert> = {
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;
}
const updatedResult = await db
.update(snippets)
.set(updateFields)
.where(
and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)),
)
.returning();
if (updatedResult.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
const updatedSnippet = updatedResult[0];
authLogger.success(
`Snippet updated: ${updatedSnippet.name} by user ${userId}`,
{
operation: "snippet_update_success",
userId,
snippetId: parseInt(id),
name: updatedSnippet.name,
},
);
res.json(updatedSnippet);
} 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 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>>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "文件",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
The
userIdis accessed via(req as any).userId, which bypasses TypeScript's type checking. To improve type safety and maintainability across the application, consider extending Express'sRequestinterface to include theuserIdproperty. This is typically done in a type definition file (e.g.,src/types/express.d.ts).Example:
With this in place, you can safely access
req.userIdwithout type casting toany.