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 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",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 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 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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user