Add terminal snippets feature with sidebar UI (#377)

* Add terminal snippets feature with sidebar UI

- Add snippets CRUD API endpoints and database schema
- Implement snippets sidebar accessible from TopNavbar
- Add copy to clipboard functionality
- Include tooltips and optimized styling
- Add English and Chinese translations

* Update src/backend/database/routes/snippets.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit was merged in pull request #377.
This commit is contained in:
Karmaa
2025-10-07 20:02:25 -05:00
committed by GitHub
parent fde64bd3df
commit cca011282c
10 changed files with 847 additions and 1 deletions

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