import { getErrorMessage } from "../lib/error-message.js";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
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,
saveUserPreferences,
} from "@/main-axios";
import {
hasSnippetInputs,
type SnippetHostContext,
} from "@/lib/snippet-variables";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
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 { FolderPathPicker } from "./FolderPathPicker";
import { Separator } from "@/components/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import {
Box,
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
Clipboard,
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 { SettingRow, FakeSwitch } from "@/components/section-card";
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 [isNote, setIsNote] = useState(false);
const [selectedHostIds, setSelectedHostIds] = useState>(
new Set(),
);
const folderMeta = useMemo(() => {
const map = new Map();
for (const f of folders) {
map.set(f.name, { color: f.color ?? undefined, icon: f.icon });
}
return map;
}, [folders]);
useEffect(() => {
if (open) {
setName(snippet?.name ?? "");
setDescription(snippet?.description ?? "");
setFolder(snippet?.folder ?? null);
setContent(snippet?.content ?? "");
setIsNote(snippet?.isNote ?? false);
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,
isNote,
hostIds:
!isNote && 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),
isNote: s.isNote ?? false,
};
}
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,
onRunSnippet,
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;
onRunSnippet: (snippet: Snippet, targets: Tab[]) => 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.isNote && (snippet.hostIds?.length ?? 0) > 0;
function handleRun() {
if (hasTargetHosts) {
onDirectExecute(snippet);
return;
}
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
onRunSnippet(snippet, targets);
} else if (terminalTabs.length > 0) {
const activeTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
onRunSnippet(snippet, [activeTab]);
} else {
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
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}
)}
{snippet.isNote && (
{t("newUi.sidebar.snippets.typeNote")}
)}
{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,
onRunSnippet,
onDirectExecute,
draggedSnippet,
dropTarget,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
availableHosts,
t,
open,
onToggleOpen,
}: {
folderName: string;
snippets: Snippet[];
selectedTabIds: Set;
terminalTabs: Tab[];
activeTabId: string;
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onRunSnippet: (snippet: Snippet, targets: Tab[]) => 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;
open: boolean;
onToggleOpen: () => void;
}) {
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}
/>
))}
)}
);
}
const UNCATEGORIZED_KEY = "__uncategorized__";
function readOpenFolders(): Set {
try {
const saved = localStorage.getItem("snippetOpenFolders");
if (saved) return new Set(JSON.parse(saved));
} catch {
// ignore malformed storage
}
return new Set();
}
export function SnippetsPanel({
terminalTabs,
activeTabId,
storageMode = "local",
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode?: "local" | "cloud";
}) {
const { t } = useTranslation();
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 [foldersCollapsedDefault, setFoldersCollapsedDefault] = useState(
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [confirmExecution, setConfirmExecution] = useState(
() => localStorage.getItem("confirmSnippetExecution") === "true",
);
const [selectedTabIds, setSelectedTabIds] = useState>(
() =>
new Set(
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
? [activeTabId]
: [],
),
);
// Path B (direct SSH execute) needs its own variables-dialog state since it
// resolves against the first target host and posts inputValues to the
// backend, unlike Path A (terminal send) which the shared hook covers.
const [directRunningSnippet, setDirectRunningSnippet] = useState<{
snippet: Snippet;
host: SnippetHostContext | null;
onConfirm: (
resolvedContent: string,
inputValues: Record,
) => void;
} | null>(null);
const {
runSnippet: handleRunSnippet,
handleConfirmRun,
dialog: runSnippetDialog,
} = useSnippetRunner();
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 [openFolders, setOpenFolders] = useState>(() =>
readOpenFolders(),
);
function persistOpenFolders(next: Set) {
try {
localStorage.setItem("snippetOpenFolders", JSON.stringify([...next]));
} catch {
// ignore quota/serialization failures
}
}
function toggleOpenFolder(key: string) {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
persistOpenFolders(next);
return next;
});
}
const allFolderKeys = useMemo(() => {
const keys = new Set([UNCATEGORIZED_KEY]);
for (const f of folders) keys.add(f.name);
for (const s of snippets) {
if (s.folder) keys.add(s.folder);
}
return keys;
}, [folders, snippets]);
useEffect(() => {
const handler = () => {
const collapsed = getFoldersCollapsed();
const next = collapsed ? new Set() : new Set(allFolderKeys);
persistOpenFolders(next);
setOpenFolders(next);
};
const expandAll = () => {
const next = new Set(allFolderKeys);
persistOpenFolders(next);
setOpenFolders(next);
};
const collapseAll = () => {
const next = new Set();
persistOpenFolders(next);
setOpenFolders(next);
};
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
window.addEventListener("snippets:expand-all", expandAll);
window.addEventListener("snippets:collapse-all", collapseAll);
return () => {
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handler,
);
window.removeEventListener("snippets:expand-all", expandAll);
window.removeEventListener("snippets:collapse-all", collapseAll);
};
}, [allFolderKeys]);
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);
}
// For folder keys the user has never explicitly toggled (not present in the
// persisted set), fall back to the default-collapsed setting instead of
// always treating them as closed.
function seedNewFolderKeys(keys: string[]) {
if (keys.length === 0) return;
const collapsed = getFoldersCollapsed();
if (collapsed) return;
setOpenFolders((prev) => {
const hadAny = localStorage.getItem("snippetOpenFolders") !== null;
const next = new Set(prev);
let changed = false;
for (const key of keys) {
if (!hadAny || !next.has(key)) {
if (!next.has(key)) {
next.add(key);
changed = true;
}
}
}
if (!changed) return prev;
persistOpenFolders(next);
return next;
});
}
useEffect(() => {
getSnippets()
.then((data) => {
const arr = (Array.isArray(data)
? data
: []) as unknown as RawSnippet[];
const mapped = arr.map(mapRawSnippet);
updateSnippets(mapped);
const folderKeys = Array.from(
new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY)),
);
seedNewFolderKeys(folderKeys);
})
.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: true,
}));
setFolders(mapped);
seedNewFolderKeys(mapped.map((f) => f.name));
})
.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 }]);
setOpenFolders((prev) => {
const next = new Set(prev);
next.add(f.name);
persistOpenFolders(next);
return next;
});
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
}
}
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,
),
);
setOpenFolders((prev) => {
if (!prev.has(folder.name)) return prev;
const next = new Set(prev);
next.delete(folder.name);
persistOpenFolders(next);
return next;
});
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,
),
);
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(oldName)) {
next.delete(oldName);
next.add(data.name);
}
persistOpenFolders(next);
return next;
});
}
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() {
getSnippets()
.then((data) => {
const arr = (Array.isArray(data)
? data
: []) as unknown as RawSnippet[];
const mapped = arr.map(mapRawSnippet);
updateSnippets(mapped);
seedNewFolderKeys(
Array.from(new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY))),
);
})
.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: true,
}));
setFolders(mapped);
seedNewFolderKeys(mapped.map((f) => f.name));
})
.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 executeDirectRun(
snippet: Snippet,
inputValues: Record,
) {
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,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
return {
hostLabel,
success: result.success,
output: result.output,
error: result.error,
};
} catch (err) {
return {
hostLabel,
success: false,
output: "",
error: getErrorMessage(err, 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 handleDirectExecute = useCallback(
(snippet: Snippet) => {
const runWithInputs = (inputValues: Record) => {
handleConfirmRun(snippet, () => {
void executeDirectRun(snippet, inputValues);
});
};
if (hasSnippetInputs(snippet.content)) {
const firstHost = snippet.hostIds?.length
? availableHosts.find((h) => h.id === snippet.hostIds![0])
: undefined;
setDirectRunningSnippet({
snippet,
host: firstHost
? {
ip: firstHost.ip,
username: firstHost.username,
port: firstHost.port,
name: firstHost.name,
}
: null,
onConfirm: (_resolvedContent, inputValues) => {
setDirectRunningSnippet(null);
runWithInputs(inputValues);
},
});
} else {
runWithInputs({});
}
},
[availableHosts, handleConfirmRun],
);
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.title")}
{t("hosts.docsLink")}
{
setFoldersCollapsedDefault(v);
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
v.toString(),
);
window.dispatchEvent(
new Event("defaultSnippetFoldersCollapsedChanged"),
);
if (storageMode === "cloud")
saveUserPreferences({ foldersCollapsed: v }).catch(
() => {},
);
}}
/>
{
setConfirmExecution(v);
localStorage.setItem(
"confirmSnippetExecution",
v.toString(),
);
if (storageMode === "cloud")
saveUserPreferences({
confirmSnippetExecution: v,
}).catch(() => {});
}}
/>
{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) && (
{openFolders.has(UNCATEGORIZED_KEY) && (
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;
const isOpen = openFolders.has(folder.name);
return (
{
setEditFolder(folder);
setEditFolderOpen(true);
}}
>
{t("newUi.sidebar.snippets.editFolder")}
handleDeleteFolder(folder)}
className="text-destructive focus:text-destructive"
>
{t("newUi.sidebar.snippets.deleteFolder")}
{isOpen && (
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 (
toggleOpenFolder(folderName)}
/>
);
})}
{runSnippetDialog}
{directRunningSnippet && (
setDirectRunningSnippet(null)}
onConfirm={directRunningSnippet.onConfirm}
/>
)}
{
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)}
/>
>
);
}