import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet as apiCreateSnippet,
updateSnippet as apiUpdateSnippet,
deleteSnippet as apiDeleteSnippet,
getSnippetFolders,
createSnippetFolder as apiCreateSnippetFolder,
deleteSnippetFolder as apiDeleteSnippetFolder,
renameSnippetFolder as apiRenameSnippetFolder,
updateSnippetFolderMetadata as apiUpdateSnippetFolderMetadata,
shareSnippet as apiShareSnippet,
getSnippetAccess,
revokeSnippetAccess,
getUserList,
getRoles,
reorderSnippets,
} from "@/main-axios";
import {
exportSnippets,
importSnippets,
type SnippetExportData,
executeSnippet as apiExecuteSnippet,
} from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHost } from "@/types/index";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Separator } from "@/components/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import {
Box,
ChevronDown,
Copy,
Cpu,
Database,
Download,
Folder,
Globe,
GripVertical,
Network,
Pencil,
Play,
Plus,
Search,
Server,
Settings,
Share2,
Terminal,
Trash2,
Upload,
UserPlus,
X,
MoreHorizontal,
Zap,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner";
import { FOLDER_COLORS } from "@/lib/theme";
import { FOLDER_ICONS } from "@/types/ui-types";
import type {
Snippet,
SnippetFolder,
FolderIconId,
Tab,
} from "@/types/ui-types";
function FolderIconEl({
icon,
className,
style,
}: {
icon: FolderIconId;
className?: string;
style?: React.CSSProperties;
}) {
const props = { className, style };
switch (icon) {
case "folder":
return ;
case "server":
return ;
case "cloud":
return (
);
case "database":
return ;
case "box":
return ;
case "network":
return ;
case "copy":
return ;
case "settings":
return ;
case "cpu":
return ;
case "globe":
return ;
}
}
function SnippetFormDialog({
open,
onOpenChange,
folders,
snippet,
onSave,
availableHosts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
folders: SnippetFolder[];
snippet: Snippet | null;
onSave: (data: Omit, id?: number) => void;
availableHosts: SSHHost[];
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [folder, setFolder] = useState(null);
const [content, setContent] = useState("");
const [selectedHostIds, setSelectedHostIds] = useState>(
new Set(),
);
useEffect(() => {
if (open) {
setName(snippet?.name ?? "");
setDescription(snippet?.description ?? "");
setFolder(snippet?.folder ?? null);
setContent(snippet?.content ?? "");
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
}
}, [open, snippet]);
function toggleHost(id: number) {
setSelectedHostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function handleSave() {
if (!name.trim() || !content.trim()) return;
onSave(
{
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
folder,
hostIds:
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
},
snippet?.id,
);
onOpenChange(false);
}
const isEdit = snippet !== null;
return (
);
}
function CreateFolderDialog({
open,
onOpenChange,
onCreate,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onCreate: (f: Omit) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState(FOLDER_COLORS[0]);
const [icon, setIcon] = useState("folder");
function handleCreate() {
if (!name.trim()) return;
onCreate({ name: name.trim(), color, icon });
setName("");
setColor(FOLDER_COLORS[0]);
setIcon("folder");
onOpenChange(false);
}
return (
);
}
function EditFolderDialog({
open,
onOpenChange,
folder,
onSave,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
folder: SnippetFolder | null;
onSave: (
oldName: string,
data: { name: string; color: string; icon: FolderIconId },
) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState(FOLDER_COLORS[0]);
const [icon, setIcon] = useState("folder");
useEffect(() => {
if (open && folder) {
setName(folder.name);
setColor(folder.color ?? FOLDER_COLORS[0]);
setIcon(folder.icon ?? "folder");
}
}, [open, folder]);
function handleSave() {
if (!name.trim() || !folder) return;
onSave(folder.name, { name: name.trim(), color, icon });
onOpenChange(false);
}
return (
);
}
type AccessRecord = {
id: number;
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
permissionLevel: string;
expiresAt: string | null;
};
type SnippetPayload = Omit;
type RawSnippet = Omit & {
id: number;
order?: number | null;
hostFilter?: string | null;
};
function parseHostFilter(raw: string | null | undefined): number[] | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "number")) {
return parsed.length > 0 ? parsed : undefined;
}
} catch {
// ignore malformed values
}
return undefined;
}
function mapRawSnippet(s: RawSnippet): Snippet {
return {
id: s.id,
name: s.name,
description: s.description,
content: s.content,
folder: s.folder ?? null,
order: s.order ?? 0,
hostIds: parseHostFilter(s.hostFilter),
};
}
type RawSnippetFolder = {
id: number;
name: string;
color?: string | null;
icon?: FolderIconId | null;
};
function ShareSnippetDialog({
snippet,
onClose,
}: {
snippet: Snippet | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [roles, setRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [accessList, setAccessList] = useState([]);
const [targetType, setTargetType] = useState<"user" | "role">("user");
const [targetId, setTargetId] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!snippet) return;
setLoading(true);
Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
.then(([usersData, rolesData, accessData]) => {
setUsers(
(usersData?.users || []).map((u) => ({
id: u.userId,
username: u.username,
})),
);
setRoles(rolesData?.roles || []);
setAccessList(accessData.accessList || []);
})
.catch(() => toast.error(t("newUi.sidebar.snippets.shareLoadError")))
.finally(() => setLoading(false));
}, [snippet, t]);
async function handleShare() {
if (!snippet || !targetId) return;
try {
await apiShareSnippet(snippet.id, {
targetType,
targetUserId: targetType === "user" ? targetId : undefined,
targetRoleId: targetType === "role" ? parseInt(targetId) : undefined,
});
toast.success(t("newUi.sidebar.snippets.shareSuccess"));
const accessData = await getSnippetAccess(snippet.id);
setAccessList(accessData.accessList || []);
setTargetId("");
} catch {
toast.error(t("newUi.sidebar.snippets.shareFailed"));
}
}
async function handleRevoke(accessId: number) {
if (!snippet) return;
try {
await revokeSnippetAccess(snippet.id, accessId);
toast.success(t("newUi.sidebar.snippets.revokeSuccess"));
const accessData = await getSnippetAccess(snippet.id);
setAccessList(accessData.accessList || []);
} catch {
toast.error(t("newUi.sidebar.snippets.revokeFailed"));
}
}
return (
);
}
function SnippetCard({
snippet,
selectedTabIds,
terminalTabs,
activeTabId,
onDelete,
onEdit,
onShare,
onConfirmRun,
onDirectExecute,
onDragStart,
onDragEnd,
onDragOver,
dropIndicator,
isDragging,
availableHosts,
t,
}: {
snippet: Snippet;
selectedTabIds: Set;
terminalTabs: Tab[];
activeTabId: string;
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onDirectExecute: (snippet: Snippet) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
dropIndicator: "above" | "below" | null;
isDragging: boolean;
availableHosts: SSHHost[];
t: (key: string, opts?: Record) => string;
}) {
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
function executeRun() {
if (hasTargetHosts) {
onDirectExecute(snippet);
return;
}
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
targets.forEach((tab) => {
tab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
});
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: targets.length,
}),
);
} else if (terminalTabs.length > 0) {
const activeTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: 1,
}),
);
} else {
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function handleRun() {
onConfirmRun(snippet, executeRun);
}
function handleCopy() {
copyToClipboard(snippet.content);
toast.success(
t("newUi.sidebar.snippets.copySuccess", { name: snippet.name }),
);
}
const targetHosts = hasTargetHosts
? availableHosts.filter((h) => snippet.hostIds!.includes(h.id))
: [];
return (
{dropIndicator === "above" && (
)}
{
e.dataTransfer.effectAllowed = "move";
onDragStart();
}}
onDragEnd={onDragEnd}
className={`border bg-background p-2.5 flex flex-col gap-2 group/card transition-opacity ${isDragging ? "opacity-40" : "opacity-100"} border-border`}
>
{snippet.name}
{snippet.description && (
{snippet.description}
)}
{hasTargetHosts && (
)}
{snippet.content}
{targetHosts.length > 0 && (
{targetHosts.map((host) => (
{host.name || host.ip}
))}
)}
{dropIndicator === "below" && (
)}
);
}
function ImportSnippetsDialog({
open,
onOpenChange,
onImportDone,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onImportDone: () => void;
}) {
const { t } = useTranslation();
const [file, setFile] = useState(null);
const [overwrite, setOverwrite] = useState(false);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
if (!open) {
setFile(null);
setOverwrite(false);
}
}, [open]);
function handleFileChange(e: React.ChangeEvent) {
setFile(e.target.files?.[0] ?? null);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
const dropped = e.dataTransfer.files?.[0];
if (dropped?.name.endsWith(".json")) setFile(dropped);
}
async function handleImport() {
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let parsed: SnippetExportData;
try {
parsed = JSON.parse(text);
} catch {
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
return;
}
if (
typeof parsed !== "object" ||
parsed === null ||
(!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
) {
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
return;
}
const result = await importSnippets(parsed, overwrite);
toast.success(
t("newUi.sidebar.snippets.importSuccess", {
snippets: result.snippetsImported,
updated: result.snippetsUpdated,
skipped: result.snippetsSkipped,
folders: result.foldersImported,
}),
);
onImportDone();
onOpenChange(false);
} catch {
toast.error(t("newUi.sidebar.snippets.importFailed"));
} finally {
setImporting(false);
}
}
return (
);
}
interface ExecutionResult {
hostLabel: string;
success: boolean;
output: string;
error?: string;
}
function ExecutionResultDialog({
open,
onOpenChange,
snippetName,
results,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
snippetName: string;
results: ExecutionResult[];
}) {
const { t } = useTranslation();
return (
);
}
function VirtualFolderGroup({
folderName,
snippets: folderSnippets,
selectedTabIds,
terminalTabs,
activeTabId,
onDelete,
onEdit,
onShare,
onConfirmRun,
onDirectExecute,
draggedSnippet,
dropTarget,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
availableHosts,
t,
defaultOpen,
}: {
folderName: string;
snippets: Snippet[];
selectedTabIds: Set;
terminalTabs: Tab[];
activeTabId: string;
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onDirectExecute: (snippet: Snippet) => void;
draggedSnippet: Snippet | null;
dropTarget: { id: number; position: "above" | "below" } | null;
onDragStart: (snippet: Snippet) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent, id: number) => void;
onDrop: (folder: string | null) => void;
availableHosts: SSHHost[];
t: (key: string, opts?: Record) => string;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
{open && (
e.preventDefault()}
onDrop={() => onDrop(folderName)}
>
{folderSnippets.map((snippet) => (
onDragStart(snippet)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, snippet.id)}
dropIndicator={
dropTarget?.id === snippet.id ? dropTarget.position : null
}
isDragging={draggedSnippet?.id === snippet.id}
availableHosts={availableHosts}
t={t}
/>
))}
)}
);
}
export function SnippetsPanel({
terminalTabs,
activeTabId,
}: {
terminalTabs: Tab[];
activeTabId: string;
}) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [snippetSearch, setSnippetSearch] = useState("");
const [folders, setFolders] = useState([]);
const [snippets, setSnippets] = useState([]);
const snippetsRef = useRef([]);
const [snippetFormOpen, setSnippetFormOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState(null);
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [editFolder, setEditFolder] = useState(null);
const [editFolderOpen, setEditFolderOpen] = useState(false);
const [shareSnippet, setShareSnippet] = useState(null);
const [importOpen, setImportOpen] = useState(false);
const [availableHosts, setAvailableHosts] = useState([]);
const [executionResultOpen, setExecutionResultOpen] = useState(false);
const [executionResults, setExecutionResults] = useState(
[],
);
const [executionSnippetName, setExecutionSnippetName] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState>(
() =>
new Set(
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
? [activeTabId]
: [],
),
);
function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
setSnippets((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
snippetsRef.current = resolved;
return resolved;
});
}
const getFoldersCollapsed = () =>
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
const [uncategorizedOpen, setUncategorizedOpen] = useState(
() => !getFoldersCollapsed(),
);
useEffect(() => {
const handler = () => {
const collapsed = getFoldersCollapsed();
setUncategorizedOpen(!collapsed);
setFolders((prev) => prev.map((f) => ({ ...f, open: !collapsed })));
};
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
return () =>
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handler,
);
}, []);
const [draggedSnippet, setDraggedSnippet] = useState(null);
const [dropTarget, setDropTarget] = useState<{
id: number;
position: "above" | "below";
} | null>(null);
function handleDragStart(snippet: Snippet) {
setDraggedSnippet(snippet);
}
function handleDragOver(e: React.DragEvent, snippetId: number) {
e.preventDefault();
if (!draggedSnippet || draggedSnippet.id === snippetId) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const position = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
setDropTarget((prev) =>
prev?.id === snippetId && prev?.position === position
? prev
: { id: snippetId, position },
);
}
async function handleDrop(folder: string | null) {
const dragged = draggedSnippet;
const target = dropTarget;
setDraggedSnippet(null);
setDropTarget(null);
if (!dragged || !target || dragged.id === target.id) return;
const group = snippetsRef.current.filter((s) => s.folder === folder);
const draggedIdx = group.findIndex((s) => s.id === dragged.id);
const targetIdx = group.findIndex((s) => s.id === target.id);
if (draggedIdx === -1 || targetIdx === -1) return;
const reordered = [...group];
reordered.splice(draggedIdx, 1);
const insertAt =
target.position === "below"
? reordered.findIndex((s) => s.id === target.id) + 1
: reordered.findIndex((s) => s.id === target.id);
reordered.splice(insertAt, 0, dragged);
const rest = snippetsRef.current.filter((s) => s.folder !== folder);
const next = [...rest, ...reordered];
snippetsRef.current = next;
setSnippets(next);
const payload = reordered.map((s, idx) => ({
id: s.id,
order: idx,
folder: folder ?? undefined,
}));
try {
await reorderSnippets(payload);
} catch {
toast.error(t("newUi.sidebar.snippets.reorderFailed"));
}
}
function handleDragEnd() {
setDraggedSnippet(null);
setDropTarget(null);
}
const handleConfirmRun = useCallback(
(snippet: Snippet, execute: () => void) => {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (!shouldConfirm) {
execute();
return;
}
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", { name: snippet.name }),
execute,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
},
[confirmWithToast, t],
);
useEffect(() => {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
updateSnippets(arr.map(mapRawSnippet));
})
.catch(() => {});
getSnippetFolders()
.then((data) => {
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
const mapped: SnippetFolder[] = arr.map((f) => ({
id: f.id,
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: !collapsed,
}));
setFolders(mapped);
})
.catch(() => {});
getSSHHosts()
.then((hosts) => setAvailableHosts(hosts))
.catch(() => {});
}, []);
function toggleTab(id: string) {
setSelectedTabIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
async function handleSaveSnippet(
data: Omit,
id?: number,
) {
const { hostIds, ...rest } = data;
const payload = {
...rest,
hostFilter: hostIds && hostIds.length > 0 ? hostIds : null,
};
try {
if (id !== undefined) {
await apiUpdateSnippet(id, payload);
updateSnippets((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
);
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
} else {
const created = await apiCreateSnippet(payload);
updateSnippets((prev) => [
...prev,
{
...data,
id:
(created.id as number) ??
Math.max(0, ...prev.map((x) => x.id)) + 1,
order: (created.order as number) ?? prev.length,
},
]);
toast.success(t("newUi.sidebar.snippets.createSuccess"));
}
} catch {
toast.error(
id !== undefined
? t("newUi.sidebar.snippets.updateFailed")
: t("newUi.sidebar.snippets.createFailed"),
);
}
}
async function handleCreateFolder(f: Omit) {
try {
const created = await apiCreateSnippetFolder({
name: f.name,
color: f.color,
icon: f.icon,
});
const id =
typeof created.id === "number" ? created.id : Number(created.id);
setFolders((prev) => [...prev, { ...f, id, open: true }]);
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
}
}
function toggleFolder(id: number) {
setFolders((prev) =>
prev.map((f) => (f.id === id ? { ...f, open: !f.open } : f)),
);
}
async function handleDeleteFolder(folder: SnippetFolder) {
try {
await apiDeleteSnippetFolder(folder.name);
setFolders((prev) => prev.filter((f) => f.id !== folder.id));
updateSnippets((prev) =>
prev.map((s) =>
s.folder === folder.name ? { ...s, folder: null } : s,
),
);
toast.success(
t("newUi.sidebar.snippets.folderDeleteSuccess", { name: folder.name }),
);
} catch {
toast.error(t("newUi.sidebar.snippets.folderDeleteFailed"));
}
}
async function handleSaveFolder(
oldName: string,
data: { name: string; color: string; icon: FolderIconId },
) {
try {
const nameChanged = data.name !== oldName;
if (nameChanged) {
await apiRenameSnippetFolder(oldName, data.name);
updateSnippets((prev) =>
prev.map((s) =>
s.folder === oldName ? { ...s, folder: data.name } : s,
),
);
}
await apiUpdateSnippetFolderMetadata(nameChanged ? data.name : oldName, {
color: data.color,
icon: data.icon,
});
setFolders((prev) =>
prev.map((f) =>
f.name === oldName
? { ...f, name: data.name, color: data.color, icon: data.icon }
: f,
),
);
toast.success(t("newUi.sidebar.snippets.folderEditSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderEditFailed"));
}
}
async function handleExport() {
try {
const data = await exportSnippets();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "snippets-export.json";
a.click();
URL.revokeObjectURL(url);
toast.success(t("newUi.sidebar.snippets.exportSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.exportFailed"));
}
}
function reloadData() {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
updateSnippets(arr.map(mapRawSnippet));
})
.catch(() => {});
getSnippetFolders()
.then((data) => {
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
const mapped: SnippetFolder[] = arr.map((f) => ({
id: f.id,
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: !collapsed,
}));
setFolders(mapped);
})
.catch(() => {});
}
async function handleDeleteSnippet(id: number) {
try {
await apiDeleteSnippet(id);
updateSnippets((prev) => prev.filter((s) => s.id !== id));
} catch {
toast.error(t("newUi.sidebar.snippets.deleteFailed"));
}
}
function handleEditSnippet(snippet: Snippet) {
setEditingSnippet(snippet);
setSnippetFormOpen(true);
}
async function handleDirectExecute(snippet: Snippet) {
const hostIds = snippet.hostIds ?? [];
if (hostIds.length === 0) return;
setExecutionSnippetName(snippet.name);
setExecutionResults([]);
setExecutionResultOpen(true);
const results: ExecutionResult[] = await Promise.all(
hostIds.map(async (hostId) => {
const host = availableHosts.find((h) => h.id === hostId);
const hostLabel = host ? host.name || host.ip : String(hostId);
try {
const result = await apiExecuteSnippet(snippet.id, hostId);
return {
hostLabel,
success: result.success,
output: result.output,
error: result.error,
};
} catch (err) {
return {
hostLabel,
success: false,
output: "",
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
setExecutionResults(results);
const allOk = results.every((r) => r.success);
if (allOk) {
toast.success(
t("newUi.sidebar.snippets.directRunSuccess", {
name: snippet.name,
count: results.length,
}),
);
} else {
toast.error(
t("newUi.sidebar.snippets.directRunPartialFail", {
name: snippet.name,
}),
);
}
}
const filtered = snippetSearch
? snippets.filter(
(s) =>
s.name.toLowerCase().includes(snippetSearch.toLowerCase()) ||
s.content.toLowerCase().includes(snippetSearch.toLowerCase()),
)
: snippets;
const uncategorizedSnippets = filtered.filter((s) => s.folder === null);
const ownedFolderNames = new Set(folders.map((f) => f.name));
const virtualFolderNames = Array.from(
new Set(
filtered
.filter((s) => s.folder !== null && !ownedFolderNames.has(s.folder!))
.map((s) => s.folder!),
),
).sort();
return (
<>
{t("newUi.sidebar.snippets.targetTerminals")}{" "}
({t("newUi.sidebar.snippets.optional")})
{terminalTabs.length > 0 && (
)}
{terminalTabs.length === 0 ? (
{t("newUi.sidebar.snippets.noTerminalTabsOpen")}
) : (
{terminalTabs.map((tab) => {
const selected = selectedTabIds.has(tab.id);
return (
);
})}
)}
setSnippetSearch(e.target.value)}
className="pl-8"
/>
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
{uncategorizedOpen && (
handleDrop(null)}
onDragOver={(e) => e.preventDefault()}
>
{uncategorizedSnippets.map((snippet) => (
handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
dropIndicator={
dropTarget?.id === snippet.id
? dropTarget.position
: null
}
isDragging={draggedSnippet?.id === snippet.id}
availableHosts={availableHosts}
t={t}
/>
))}
{uncategorizedSnippets.length === 0 && (
{t("newUi.sidebar.snippets.noSnippetsInFolder")}
)}
)}
)}
{folders.map((folder) => {
const folderSnippets = filtered.filter(
(s) => s.folder === folder.name,
);
if (folderSnippets.length === 0 && snippetSearch) return null;
return (
{
setEditFolder(folder);
setEditFolderOpen(true);
}}
>
{t("newUi.sidebar.snippets.editFolder")}
handleDeleteFolder(folder)}
className="text-destructive focus:text-destructive"
>
{t("newUi.sidebar.snippets.deleteFolder")}
{folder.open && (
e.preventDefault()}
onDrop={() => handleDrop(folder.name)}
>
{folderSnippets.map((snippet) => (
handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
dropIndicator={
dropTarget?.id === snippet.id
? dropTarget.position
: null
}
isDragging={draggedSnippet?.id === snippet.id}
availableHosts={availableHosts}
t={t}
/>
))}
{folderSnippets.length === 0 && (
{t("newUi.sidebar.snippets.noSnippetsInFolder")}
)}
)}
);
})}
{virtualFolderNames.map((folderName) => {
const folderSnippets = filtered.filter(
(s) => s.folder === folderName,
);
if (folderSnippets.length === 0) return null;
return (
);
})}
{
setSnippetFormOpen(v);
if (!v) setEditingSnippet(null);
}}
folders={folders}
snippet={editingSnippet}
onSave={handleSaveSnippet}
availableHosts={availableHosts}
/>
{
setEditFolderOpen(v);
if (!v) setEditFolder(null);
}}
folder={editFolder}
onSave={handleSaveFolder}
/>
setShareSnippet(null)}
/>
>
);
}