Add terminal snippets feature with sidebar UI #377

Merged
LukeGus merged 2 commits from feature-snippets-in-ssh-tools into dev-1.8.0 2025-10-08 01:02:25 +00:00
10 changed files with 847 additions and 1 deletions

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";
@@ -30,6 +31,7 @@ import {
dismissedAlerts,
sshCredentialUsage,
settings,
snippets,
} from "./db/schema.js";
import { getDb } from "./db/index.js";
import Database from "better-sqlite3";
@@ -1411,6 +1413,7 @@ app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use(
(

View File

@@ -242,6 +242,17 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
migrateSchema();

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`),
});

View File

@@ -0,0 +1,251 @@
import express from "express";
gemini-code-assist[bot] commented 2025-10-08 00:52:57 +00:00 (Migrated from github.com)
Review

high

The route parameter id is used with parseInt without validation. If id is not a string that can be parsed into a number (e.g., an empty string or non-numeric characters), parseInt will return NaN. This could lead to unexpected behavior or errors in the database query. You should validate that id is a valid number before using it.

This same validation should be applied to the PUT /:id and DELETE /:id routes as well.

    const { id } = req.params;
    const snippetId = parseInt(id, 10);

    if (!isNonEmptyString(userId) || isNaN(snippetId)) {
      authLogger.warn("Invalid request for snippet fetch: invalid ID", { userId, id });
      return res.status(400).json({ error: "Invalid request parameters" });
    }
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The route parameter `id` is used with `parseInt` without validation. If `id` is not a string that can be parsed into a number (e.g., an empty string or non-numeric characters), `parseInt` will return `NaN`. This could lead to unexpected behavior or errors in the database query. You should validate that `id` is a valid number before using it. This same validation should be applied to the `PUT /:id` and `DELETE /:id` routes as well. ```suggestion const { id } = req.params; const snippetId = parseInt(id, 10); if (!isNonEmptyString(userId) || isNaN(snippetId)) { authLogger.warn("Invalid request for snippet fetch: invalid ID", { userId, id }); return res.status(400).json({ error: "Invalid request parameters" }); } ```
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;
const snippetId = parseInt(id, 10);
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
authLogger.warn("Invalid request for snippet fetch: invalid ID", { userId, id });
return res.status(400).json({ error: "Invalid request parameters" });
}
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;
gemini-code-assist[bot] commented 2025-10-08 00:52:57 +00:00 (Migrated from github.com)
Review

high

The update endpoint allows setting name and content to empty strings, which is inconsistent with the creation endpoint that requires non-empty strings via isNonEmptyString. You should add validation to ensure name and content are not empty if they are being updated.

Additionally, you can avoid using any for updateFields by using a more specific type like Partial<typeof snippets.$inferInsert> for better type safety.

      const updateFields: Partial<typeof snippets.$inferInsert> = {
        updatedAt: sql`CURRENT_TIMESTAMP`,
      };

      if (updateData.name !== undefined) {
        if (!isNonEmptyString(updateData.name)) {
          return res.status(400).json({ error: "Name cannot be empty" });
        }
        updateFields.name = updateData.name.trim();
      }
      if (updateData.content !== undefined) {
        if (!isNonEmptyString(updateData.content)) {
          return res.status(400).json({ error: "Content cannot be empty" });
        }
        updateFields.content = updateData.content.trim();
      }
      if (updateData.description !== undefined) {
        updateFields.description = updateData.description?.trim() || null;
      }
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The update endpoint allows setting `name` and `content` to empty strings, which is inconsistent with the creation endpoint that requires non-empty strings via `isNonEmptyString`. You should add validation to ensure `name` and `content` are not empty if they are being updated. Additionally, you can avoid using `any` for `updateFields` by using a more specific type like `Partial<typeof snippets.$inferInsert>` for better type safety. ```typescript const updateFields: Partial<typeof snippets.$inferInsert> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; if (updateData.name !== undefined) { if (!isNonEmptyString(updateData.name)) { return res.status(400).json({ error: "Name cannot be empty" }); } updateFields.name = updateData.name.trim(); } if (updateData.content !== undefined) { if (!isNonEmptyString(updateData.content)) { return res.status(400).json({ error: "Content cannot be empty" }); } 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)));
gemini-code-assist[bot] commented 2025-10-08 00:52:57 +00:00 (Migrated from github.com)
Review

medium

To improve efficiency, you can use Drizzle's .returning() method on the update query. This will return the updated record in a single database call, avoiding the need for a separate select query.

      const updated = await db
        .update(snippets)
        .set(updateFields)
        .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)))
        .returning();
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) To improve efficiency, you can use Drizzle's `.returning()` method on the update query. This will return the updated record in a single database call, avoiding the need for a separate `select` query. ```typescript const updated = await db .update(snippets) .set(updateFields) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))) .returning(); ```
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;

View File

@@ -191,6 +191,40 @@
"enableRightClickCopyPaste": "Enable rightclick copy/paste",
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on"
},
"snippets": {
"title": "Snippets",
"new": "New Snippet",
"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}}",
"copySuccess": "Copied \"{{name}}\" to clipboard",
"runTooltip": "Execute this snippet in the terminal",
"copyTooltip": "Copy snippet to clipboard",
"editTooltip": "Edit this snippet",
"deleteTooltip": "Delete this snippet"
},
"homepage": {
"loggedInTitle": "Logged in!",
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
@@ -356,6 +390,7 @@
"admin": "Admin",
"userProfile": "User Profile",
"tools": "Tools",
"snippets": "Snippets",
"newTab": "New Tab",
"splitScreen": "Split Screen",
"closeTab": "Close Tab",

View File

@@ -189,6 +189,40 @@
"enableRightClickCopyPaste": "启用右键复制/粘贴",
"shareIdeas": "对 SSH 工具有什么想法?在此分享"
},
"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}}",
"copySuccess": "已复制 \"{{name}}\" 到剪贴板",
"runTooltip": "在终端中执行此片段",
"copyTooltip": "复制片段到剪贴板",
"editTooltip": "编辑此片段",
"deleteTooltip": "删除此片段"
},
"homepage": {
"loggedInTitle": "登录成功!",
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
@@ -342,6 +376,7 @@
"admin": "管理员",
"userProfile": "用户资料",
"tools": "工具",
"snippets": "代码片段",
"newTab": "新标签页",
"splitScreen": "分屏",
"closeTab": "关闭标签页",

View File

@@ -413,6 +413,26 @@ export interface FolderStats {
}>;
}
// ============================================================================
// SNIPPETS 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;
}
// ============================================================================
// BACKEND TYPES
// ============================================================================

View File

@@ -0,0 +1,407 @@
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 { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Play, Edit, Trash2, Copy } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
} from "@/ui/main-axios";
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 data = await getSnippets();
// Defensive: ensure data is an array
setSnippets(Array.isArray(data) ? 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 = (snippet: Snippet) => {
confirmWithToast(
t("snippets.deleteConfirmDescription", { name: snippet.name }),
async () => {
try {
await deleteSnippet(snippet.id);
toast.success(t("snippets.deleteSuccess"));
fetchSnippets();
} catch (err) {
toast.error(t("snippets.deleteFailed"));
}
},
"destructive",
);
};
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 updateSnippet(editingSnippet.id, formData);
toast.success(t("snippets.updateSuccess"));
} else {
await createSnippet(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 }));
};
const handleCopy = (snippet: Snippet) => {
navigator.clipboard.writeText(snippet.content);
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
if (!isOpen) return null;
return (
<>
{/* Overlay and Sidebar */}
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
}}
>
<div
className="flex-1 cursor-pointer"
onClick={onClose}
/>
<div
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
style={{
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("snippets.title")}
</h2>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<Button
onClick={handleCreate}
className="w-full"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
{loading ? (
<div className="text-center text-muted-foreground py-8">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<p className="mb-2 font-medium">{t("snippets.empty")}</p>
<p className="text-sm">{t("snippets.emptyHint")}</p>
</div>
) : (
<TooltipProvider>
<div className="space-y-3">
{snippets.map((snippet) => (
<div
key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2">
<h3 className="text-sm font-medium text-white mb-1">
{snippet.name}
</h3>
{snippet.description && (
<p className="text-xs text-muted-foreground">
{snippet.description}
</p>
)}
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.runTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(snippet)}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.copyTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
>
<Edit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.editTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.deleteTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</TooltipProvider>
)}
</div>
</div>
</div>
</div>
{/* Create/Edit Dialog - centered modal */}
{showDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowDialog(false)}
>
<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"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<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-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{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 focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{formErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.nameRequired")}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.description")}
<span className="text-muted-foreground ml-1">
({t("common.optional")})
</span>
</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder={t("snippets.descriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{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 text-sm ${formErrors.content ? "border-destructive focus-visible:ring-destructive" : ""}`}
rows={10}
/>
{formErrors.content && (
<p className="text-xs text-destructive mt-1">
{t("snippets.contentRequired")}
</p>
)}
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<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>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Button } from "@/components/ui/button.tsx";
import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
import { ChevronDown, ChevronUpIcon, Hammer, FileText } from "lucide-react";
import { Tab } from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {
@@ -16,6 +16,7 @@ import { Separator } from "@/components/ui/separator.tsx";
import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
import { getCookie, setCookie } from "@/ui/main-axios.ts";
import { SnippetsSidebar } from "@/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx";
interface TopNavbarProps {
isTopbarOpen: boolean;
@@ -41,6 +42,7 @@ export function TopNavbar({
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId);
@@ -212,6 +214,13 @@ export function TopNavbar({
}
};
const handleSnippetExecute = (content: string) => {
const tab = tabs.find((t: any) => t.id === currentTab);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(content + "\n");
}
};
const isSplitScreenActive =
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
@@ -317,6 +326,16 @@ export function TopNavbar({
<Hammer className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="w-[30px] h-[30px]"
title={t("nav.snippets")}
onClick={() => setSnippetsSidebarOpen(true)}
disabled={!currentTabObj || currentTabObj.type !== "terminal"}
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setIsTopbarOpen(false)}
@@ -484,6 +503,12 @@ export function TopNavbar({
</div>
</div>
)}
<SnippetsSidebar
isOpen={snippetsSidebarOpen}
onClose={() => setSnippetsSidebarOpen(false)}
onExecute={handleSnippetExecute}
/>
</div>
);
}

View File

@@ -2155,3 +2155,46 @@ export async function deployCredentialToHost(
throw handleApiError(error, "deploy credential to host");
}
}
// ============================================================================
// SNIPPETS API
// ============================================================================
export async function getSnippets(): Promise<any> {
try {
const response = await authApi.get("/snippets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippets");
}
}
export async function createSnippet(snippetData: any): Promise<any> {
try {
const response = await authApi.post("/snippets", snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet");
}
}
export async function updateSnippet(
snippetId: number,
snippetData: any,
): Promise<any> {
try {
const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet");
}
}
export async function deleteSnippet(snippetId: number): Promise<any> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet");
}
}
gemini-code-assist[bot] commented 2025-10-08 00:52:57 +00:00 (Migrated from github.com)
Review

medium

To improve type safety and maintainability, you should use the specific Snippet and SnippetData types for the parameters and return values of these API functions instead of any. You'll need to add Snippet and SnippetData to your imports from ../types/index.js.

export async function getSnippets(): Promise<Snippet[]> {
  try {
    const response = await authApi.get<Snippet[]>("/snippets");
    return response.data;
  } catch (error) {
    throw handleApiError(error, "fetch snippets");
  }
}

export async function createSnippet(snippetData: SnippetData): Promise<Snippet> {
  try {
    const response = await authApi.post<Snippet>("/snippets", snippetData);
    return response.data;
  } catch (error) {
    throw handleApiError(error, "create snippet");
  }
}

export async function updateSnippet(
  snippetId: number,
  snippetData: Partial<SnippetData>,
): Promise<Snippet> {
  try {
    const response = await authApi.put<Snippet>(
      `/snippets/${snippetId}`,
      snippetData,
    );
    return response.data;
  } catch (error) {
    throw handleApiError(error, "update snippet");
  }
}

export async function deleteSnippet(snippetId: number): Promise<{ success: boolean }> {
  try {
    const response = await authApi.delete<{ success: boolean }>(
      `/snippets/${snippetId}`,
    );
    return response.data;
  } catch (error) {
    throw handleApiError(error, "delete snippet");
  }
}
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) To improve type safety and maintainability, you should use the specific `Snippet` and `SnippetData` types for the parameters and return values of these API functions instead of `any`. You'll need to add `Snippet` and `SnippetData` to your imports from `../types/index.js`. ```typescript export async function getSnippets(): Promise<Snippet[]> { try { const response = await authApi.get<Snippet[]>("/snippets"); return response.data; } catch (error) { throw handleApiError(error, "fetch snippets"); } } export async function createSnippet(snippetData: SnippetData): Promise<Snippet> { try { const response = await authApi.post<Snippet>("/snippets", snippetData); return response.data; } catch (error) { throw handleApiError(error, "create snippet"); } } export async function updateSnippet( snippetId: number, snippetData: Partial<SnippetData>, ): Promise<Snippet> { try { const response = await authApi.put<Snippet>( `/snippets/${snippetId}`, snippetData, ); return response.data; } catch (error) { throw handleApiError(error, "update snippet"); } } export async function deleteSnippet(snippetId: number): Promise<{ success: boolean }> { try { const response = await authApi.delete<{ success: boolean }>( `/snippets/${snippetId}`, ); return response.data; } catch (error) { throw handleApiError(error, "delete snippet"); } } ```