Add terminal code snippets feature #341

Merged
ZacharyZcR merged 4 commits from feature/terminal-snippets into dev-1.8.0 2025-10-04 01:35:22 +00:00
9 changed files with 719 additions and 1 deletions
Showing only changes of commit f92cfbfec7 - Show all commits
+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;
gemini-code-assist[bot] commented 2025-10-03 22:54:14 +00:00 (Migrated from github.com)
Review

medium

The userId is accessed via (req as any).userId, which bypasses TypeScript's type checking. To improve type safety and maintainability across the application, consider extending Express's Request interface to include the userId property. This is typically done in a type definition file (e.g., src/types/express.d.ts).

Example:

// src/types/express.d.ts
declare namespace Express {
  export interface Request {
    userId?: string;
  }
}

With this in place, you can safely access req.userId without type casting to any.

![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The `userId` is accessed via `(req as any).userId`, which bypasses TypeScript's type checking. To improve type safety and maintainability across the application, consider extending Express's `Request` interface to include the `userId` property. This is typically done in a type definition file (e.g., `src/types/express.d.ts`). Example: ```typescript // src/types/express.d.ts declare namespace Express { export interface Request { userId?: string; } } ``` With this in place, you can safely access `req.userId` without type casting to `any`.
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",
});
gemini-code-assist[bot] commented 2025-10-03 22:54:14 +00:00 (Migrated from github.com)
Review

medium

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.

      authLogger.error("Failed to fetch snippet", err);
      res.status(500).json({ error: "Failed to fetch snippet" });
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) 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) {
gemini-code-assist[bot] commented 2025-10-03 22:54:14 +00:00 (Migrated from github.com)
Review

high

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.

    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) {
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) 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;
+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>
);
});
1