Files
Termix/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
2025-12-23 16:14:44 -06:00

2187 lines
81 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarProvider,
SidebarGroupLabel,
} from "@/components/ui/sidebar.tsx";
import {
Plus,
Play,
Edit,
Trash2,
Copy,
X,
RotateCcw,
Search,
Loader2,
Terminal,
LayoutGrid,
MonitorCheck,
Folder,
ChevronDown,
ChevronRight,
GripVertical,
FolderPlus,
Settings,
MoreVertical,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
getCookie,
setCookie,
getCommandHistory,
deleteCommandFromHistory,
getSnippetFolders,
createSnippetFolder,
updateSnippetFolderMetadata,
renameSnippetFolder,
deleteSnippetFolder,
reorderSnippets,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData, SnippetFolder } from "../../../../types";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
hostConfig?: {
id: number;
};
isActive?: boolean;
[key: string]: unknown;
}
interface SSHToolsSidebarProps {
isOpen: boolean;
onClose: () => void;
onSnippetExecute: (content: string) => void;
sidebarWidth: number;
setSidebarWidth: (width: number) => void;
initialTab?: string;
onTabChange?: () => void;
}
const AVAILABLE_COLORS = [
{ value: "#ef4444", label: "Red" },
{ value: "#f97316", label: "Orange" },
{ value: "#eab308", label: "Yellow" },
{ value: "#22c55e", label: "Green" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#a855f7", label: "Purple" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
];
const AVAILABLE_ICONS = [
{ value: "Folder", label: "Folder", Icon: Folder },
{ value: "Server", label: "Server", Icon: Server },
{ value: "Cloud", label: "Cloud", Icon: Cloud },
{ value: "Database", label: "Database", Icon: Database },
{ value: "Box", label: "Box", Icon: Box },
{ value: "Package", label: "Package", Icon: Package },
{ value: "Layers", label: "Layers", Icon: Layers },
{ value: "Archive", label: "Archive", Icon: Archive },
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
{ value: "Globe", label: "Globe", Icon: Globe },
];
export function SSHToolsSidebar({
isOpen,
onClose,
onSnippetExecute,
sidebarWidth,
setSidebarWidth,
initialTab,
onTabChange,
}: SSHToolsSidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const {
tabs,
currentTab,
allSplitScreenTab,
setSplitScreenTab,
setCurrentTab,
} = useTabs() as {
tabs: TabData[];
currentTab: number | null;
allSplitScreenTab: number[];
setSplitScreenTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
};
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
useEffect(() => {
if (initialTab && isOpen) {
setActiveTab(initialTab);
}
}, [initialTab, isOpen]);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (onTabChange) {
onTabChange();
}
};
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
() => getCookie("rightClickCopyPaste") === "true",
);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
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,
});
const [selectedSnippetTabIds, setSelectedSnippetTabIds] = useState<number[]>(
[],
);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set();
});
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null,
);
const [folderFormData, setFolderFormData] = useState({
name: "",
color: "",
icon: "",
});
const [folderFormErrors, setFolderFormErrors] = useState({
name: false,
});
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
new Map(),
);
const [previewKey, setPreviewKey] = useState(0);
const [draggedTabId, setDraggedTabId] = useState<number | null>(null);
const [dragOverCellIndex, setDragOverCellIndex] = useState<number | null>(
null,
);
const [isResizing, setIsResizing] = useState(false);
const startXRef = React.useRef<number | null>(null);
const startWidthRef = React.useRef<number>(sidebarWidth);
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
const activeTerminal =
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
const splittableTabs = tabs.filter(
(tab: TabData) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager" ||
tab.type === "user_profile",
);
useEffect(() => {
let cancelled = false;
if (isOpen && activeTab === "command-history") {
if (activeTerminalHostId) {
const scrollTop = commandHistoryScrollRef.current?.scrollTop || 0;
setIsHistoryLoading(true);
setHistoryError(null);
getCommandHistory(activeTerminalHostId)
.then((history) => {
if (cancelled) return;
setCommandHistory((prevHistory) => {
const newHistory = Array.isArray(history) ? history : [];
if (JSON.stringify(prevHistory) !== JSON.stringify(newHistory)) {
requestAnimationFrame(() => {
if (commandHistoryScrollRef.current) {
commandHistoryScrollRef.current.scrollTop = scrollTop;
}
});
return newHistory;
}
return prevHistory;
});
setIsHistoryLoading(false);
})
.catch((err) => {
if (cancelled) return;
console.error("Failed to fetch command history", err);
const errorMessage =
err?.response?.status === 401
? t("commandHistory.authRequiredRefresh")
: err?.response?.status === 403
? t("commandHistory.dataAccessLockedReauth")
: err?.message || "Failed to load command history";
setHistoryError(errorMessage);
setCommandHistory([]);
setIsHistoryLoading(false);
});
} else {
setCommandHistory([]);
setHistoryError(null);
setIsHistoryLoading(false);
}
}
return () => {
cancelled = true;
};
}, [
isOpen,
activeTab,
activeTerminalHostId,
currentTab,
historyRefreshCounter,
]);
useEffect(() => {
if (isOpen && activeTab === "command-history" && activeTerminalHostId) {
const refreshInterval = setInterval(() => {
setHistoryRefreshCounter((prev) => prev + 1);
}, 2000);
return () => clearInterval(refreshInterval);
}
}, [isOpen, activeTab, activeTerminalHostId]);
const filteredCommands = searchQuery
? commandHistory.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
)
: commandHistory;
useEffect(() => {
document.documentElement.style.setProperty(
"--right-sidebar-width",
`${sidebarWidth}px`,
);
}, [sidebarWidth]);
useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3);
if (sidebarWidth > maxWidth) {
setSidebarWidth(Math.max(minWidth, maxWidth));
} else if (sidebarWidth < minWidth) {
setSidebarWidth(minWidth);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [sidebarWidth, setSidebarWidth]);
useEffect(() => {
if (isOpen && activeTab === "snippets") {
fetchSnippets();
}
}, [isOpen, activeTab]);
useEffect(() => {
if (snippetFolders.length > 0) {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
}
}, [snippetFolders, snippets]);
useEffect(() => {
const handleSettingChange = () => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
};
window.addEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
return () => {
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
};
}, [snippetFolders, snippets]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = sidebarWidth;
};
React.useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (startXRef.current == null) return;
const dx = startXRef.current - e.clientX;
const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.round(window.innerWidth * 0.3);
let finalWidth = newWidth;
if (newWidth < minWidth) {
finalWidth = minWidth;
} else if (newWidth > maxWidth) {
finalWidth = maxWidth;
}
document.documentElement.style.setProperty(
"--right-sidebar-width",
`${finalWidth}px`,
);
setSidebarWidth(finalWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
startXRef.current = null;
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing]);
const handleTabToggle = (tabId: number) => {
setSelectedTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleStartRecording = () => {
setIsRecording(true);
setTimeout(() => {
const input = document.getElementById(
"ssh-tools-input",
) as HTMLInputElement;
if (input) input.focus();
}, 100);
};
const handleStopRecording = () => {
setIsRecording(false);
setSelectedTabIds([]);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
let commandToSend = "";
if (e.ctrlKey || e.metaKey) {
if (e.key === "c") {
commandToSend = "\x03";
e.preventDefault();
} else if (e.key === "d") {
commandToSend = "\x04";
e.preventDefault();
} else if (e.key === "l") {
commandToSend = "\x0c";
e.preventDefault();
} else if (e.key === "u") {
commandToSend = "\x15";
e.preventDefault();
} else if (e.key === "k") {
commandToSend = "\x0b";
e.preventDefault();
} else if (e.key === "a") {
commandToSend = "\x01";
e.preventDefault();
} else if (e.key === "e") {
commandToSend = "\x05";
e.preventDefault();
} else if (e.key === "w") {
commandToSend = "\x17";
e.preventDefault();
}
} else if (e.key === "Enter") {
commandToSend = "\n";
e.preventDefault();
} else if (e.key === "Backspace") {
commandToSend = "\x08";
e.preventDefault();
} else if (e.key === "Delete") {
commandToSend = "\x7f";
e.preventDefault();
} else if (e.key === "Tab") {
commandToSend = "\x09";
e.preventDefault();
} else if (e.key === "Escape") {
commandToSend = "\x1b";
e.preventDefault();
} else if (e.key === "ArrowUp") {
commandToSend = "\x1b[A";
e.preventDefault();
} else if (e.key === "ArrowDown") {
commandToSend = "\x1b[B";
e.preventDefault();
} else if (e.key === "ArrowLeft") {
commandToSend = "\x1b[D";
e.preventDefault();
} else if (e.key === "ArrowRight") {
commandToSend = "\x1b[C";
e.preventDefault();
} else if (e.key === "Home") {
commandToSend = "\x1b[H";
e.preventDefault();
} else if (e.key === "End") {
commandToSend = "\x1b[F";
e.preventDefault();
} else if (e.key === "PageUp") {
commandToSend = "\x1b[5~";
e.preventDefault();
} else if (e.key === "PageDown") {
commandToSend = "\x1b[6~";
e.preventDefault();
} else if (e.key === "Insert") {
commandToSend = "\x1b[2~";
e.preventDefault();
} else if (e.key === "F1") {
commandToSend = "\x1bOP";
e.preventDefault();
} else if (e.key === "F2") {
commandToSend = "\x1bOQ";
e.preventDefault();
} else if (e.key === "F3") {
commandToSend = "\x1bOR";
e.preventDefault();
} else if (e.key === "F4") {
commandToSend = "\x1bOS";
e.preventDefault();
} else if (e.key === "F5") {
commandToSend = "\x1b[15~";
e.preventDefault();
} else if (e.key === "F6") {
commandToSend = "\x1b[17~";
e.preventDefault();
} else if (e.key === "F7") {
commandToSend = "\x1b[18~";
e.preventDefault();
} else if (e.key === "F8") {
commandToSend = "\x1b[19~";
e.preventDefault();
} else if (e.key === "F9") {
commandToSend = "\x1b[20~";
e.preventDefault();
} else if (e.key === "F10") {
commandToSend = "\x1b[21~";
e.preventDefault();
} else if (e.key === "F11") {
commandToSend = "\x1b[23~";
e.preventDefault();
} else if (e.key === "F12") {
commandToSend = "\x1b[24~";
e.preventDefault();
}
if (commandToSend) {
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(commandToSend);
}
});
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const updateRightClickCopyPaste = (checked: boolean) => {
setCookie("rightClickCopyPaste", checked.toString());
setRightClickCopyPaste(checked);
};
const fetchSnippets = async () => {
try {
setLoading(true);
const [snippetsData, foldersData] = await Promise.all([
getSnippets(),
getSnippetFolders(),
]);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
setSnippetFolders([]);
} 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 || "",
folder: snippet.folder,
});
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 {
toast.error(t("snippets.deleteFailed"));
}
},
"destructive",
);
};
const handleSubmit = async () => {
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 {
toast.error(
editingSnippet
? t("snippets.updateFailed")
: t("snippets.createFailed"),
);
}
};
const handleSnippetTabToggle = (tabId: number) => {
setSelectedSnippetTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleExecute = (snippet: Snippet) => {
if (selectedSnippetTabIds.length > 0) {
selectedSnippetTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(snippet.content + "\n");
}
});
toast.success(
t("snippets.executeSuccess", {
name: snippet.name,
count: selectedSnippetTabIds.length,
}),
);
} else {
onSnippetExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
// Remove focus from any active element in the sidebar to prevent accidental re-execution
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const handleCopy = (snippet: Snippet) => {
navigator.clipboard.writeText(snippet.content);
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
const toggleFolder = (folderName: string) => {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) {
next.delete(folderName);
} else {
next.add(folderName);
}
return next;
});
};
const getFolderIcon = (folderName: string) => {
const metadata = snippetFolders.find((f) => f.name === folderName);
if (!metadata?.icon) return Folder;
const iconData = AVAILABLE_ICONS.find((i) => i.value === metadata.icon);
return iconData?.Icon || Folder;
};
const getFolderColor = (folderName: string) => {
const metadata = snippetFolders.find((f) => f.name === folderName);
return metadata?.color;
};
const groupSnippetsByFolder = () => {
const grouped = new Map<string, Snippet[]>();
snippetFolders.forEach((folder) => {
if (!grouped.has(folder.name)) {
grouped.set(folder.name, []);
}
});
snippets.forEach((snippet) => {
const folderName = snippet.folder || "";
if (!grouped.has(folderName)) {
grouped.set(folderName, []);
}
grouped.get(folderName)!.push(snippet);
});
return grouped;
};
const handleDragStart = (e: React.DragEvent, snippet: Snippet) => {
setDraggedSnippet(snippet);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, targetSnippet: Snippet) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDragEnterFolder = (folderName: string) => {
setDragOverFolder(folderName);
};
const handleDragLeaveFolder = () => {
setDragOverFolder(null);
};
const handleDrop = async (e: React.DragEvent, targetSnippet: Snippet) => {
e.preventDefault();
if (!draggedSnippet || draggedSnippet.id === targetSnippet.id) {
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const sourceFolder = draggedSnippet.folder || "";
const targetFolder = targetSnippet.folder || "";
if (sourceFolder !== targetFolder) {
toast.error(
t("snippets.reorderSameFolder"),
);
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const folderSnippets = snippets.filter(
(s) => (s.folder || "") === targetFolder,
);
const draggedIndex = folderSnippets.findIndex(
(s) => s.id === draggedSnippet.id,
);
const targetIndex = folderSnippets.findIndex(
(s) => s.id === targetSnippet.id,
);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const reorderedSnippets = [...folderSnippets];
reorderedSnippets.splice(draggedIndex, 1);
reorderedSnippets.splice(targetIndex, 0, draggedSnippet);
const updates = reorderedSnippets.map((snippet, index) => ({
id: snippet.id,
order: index,
folder: targetFolder || undefined,
}));
try {
await reorderSnippets(updates);
toast.success(
t("snippets.reorderSuccess"),
);
fetchSnippets();
} catch {
toast.error(
t("snippets.reorderFailed"),
);
}
setDraggedSnippet(null);
setDragOverFolder(null);
};
const handleDragEnd = () => {
setDraggedSnippet(null);
setDragOverFolder(null);
};
const handleCreateFolder = () => {
setEditingFolder(null);
setFolderFormData({
name: "",
color: AVAILABLE_COLORS[0].value,
icon: AVAILABLE_ICONS[0].value,
});
setFolderFormErrors({ name: false });
setShowFolderDialog(true);
};
const handleEditFolder = (folder: SnippetFolder) => {
setEditingFolder(folder);
setFolderFormData({
name: folder.name,
color: folder.color || AVAILABLE_COLORS[0].value,
icon: folder.icon || AVAILABLE_ICONS[0].value,
});
setFolderFormErrors({ name: false });
setShowFolderDialog(true);
};
const handleDeleteFolder = (folderName: string) => {
confirmWithToast(
t("snippets.deleteFolderConfirm", {
name: folderName,
async () => {
try {
await deleteSnippetFolder(folderName);
toast.success(t("snippets.deleteFolderSuccess"));
fetchSnippets();
} catch {
toast.error(t("snippets.deleteFolderFailed"));
}
},
"destructive",
);
};
const handleFolderSubmit = async () => {
const errors = {
name: !folderFormData.name.trim(),
};
setFolderFormErrors(errors);
if (errors.name) {
return;
}
try {
if (editingFolder) {
if (editingFolder.name !== folderFormData.name) {
await renameSnippetFolder(editingFolder.name, folderFormData.name);
}
await updateSnippetFolderMetadata(folderFormData.name, {
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(t("snippets.updateFolderSuccess"));
} else {
await createSnippetFolder({
name: folderFormData.name,
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(t("snippets.createFolderSuccess"));
}
setShowFolderDialog(false);
fetchSnippets();
} catch {
toast.error(
editingFolder
? t("snippets.updateFolderFailed")
: t("snippets.createFolderFailed"),
);
}
};
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
setSplitMode(mode);
if (mode === "none") {
handleClearSplit();
} else {
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
}
};
const handleTabDragStart = (tabId: number) => {
setDraggedTabId(tabId);
};
const handleTabDragEnd = () => {
setDraggedTabId(null);
setDragOverCellIndex(null);
};
const handleTabDragOver = (e: React.DragEvent, cellIndex: number) => {
e.preventDefault();
setDragOverCellIndex(cellIndex);
};
const handleTabDragLeave = () => {
setDragOverCellIndex(null);
};
const handleTabDrop = (cellIndex: number) => {
if (draggedTabId === null) return;
setSplitAssignments((prev) => {
const newMap = new Map(prev);
Array.from(newMap.entries()).forEach(([idx, id]) => {
if (id === draggedTabId && idx !== cellIndex) {
newMap.delete(idx);
}
});
newMap.set(cellIndex, draggedTabId);
return newMap;
});
setDraggedTabId(null);
setDragOverCellIndex(null);
setPreviewKey((prev) => prev + 1);
};
const handleRemoveFromCell = (cellIndex: number) => {
setSplitAssignments((prev) => {
const newMap = new Map(prev);
newMap.delete(cellIndex);
setPreviewKey((prev) => prev + 1);
return newMap;
});
};
const handleApplySplit = () => {
if (splitMode === "none") {
handleClearSplit();
return;
}
if (splitAssignments.size === 0) {
toast.error(
t("splitScreen.error.noAssignments"),
);
return;
}
const requiredSlots = parseInt(splitMode);
if (splitAssignments.size < requiredSlots) {
toast.error(
t("splitScreen.error.fillAllSlots", {
count: requiredSlots,
}),
);
return;
}
const orderedTabIds: number[] = [];
for (let i = 0; i < requiredSlots; i++) {
const tabId = splitAssignments.get(i);
if (tabId !== undefined) {
orderedTabIds.push(tabId);
}
}
const currentSplits = [...allSplitScreenTab];
currentSplits.forEach((tabId) => {
setSplitScreenTab(tabId);
});
orderedTabIds.forEach((tabId) => {
setSplitScreenTab(tabId);
});
if (!orderedTabIds.includes(currentTab ?? 0)) {
setCurrentTab(orderedTabIds[0]);
}
toast.success(
t("splitScreen.success"),
);
};
const handleClearSplit = () => {
allSplitScreenTab.forEach((tabId) => {
setSplitScreenTab(tabId);
});
setSplitMode("none");
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
toast.success(
t("splitScreen.cleared"),
);
};
const handleResetToSingle = () => {
handleClearSplit();
};
const handleCommandSelect = (command: string) => {
if (activeTerminal?.terminalRef?.current?.sendInput) {
activeTerminal.terminalRef.current.sendInput(command);
}
};
const handleCommandDelete = async (command: string) => {
if (activeTerminalHostId) {
try {
await deleteCommandFromHistory(activeTerminalHostId, command);
setCommandHistory((prev) => prev.filter((c) => c !== command));
toast.success(
t("commandHistory.deleteSuccess"),
);
} catch {
toast.error(
t("commandHistory.deleteFailed"),
);
}
}
};
return (
<>
{isOpen && (
<div className="fixed top-0 right-0 h-0 w-0 pointer-events-none">
<SidebarProvider
open={isOpen}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
className="!min-h-0 !h-0 !w-0"
>
<Sidebar
variant="floating"
side="right"
className="pointer-events-auto"
>
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
{t("nav.tools")}
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(400)}
className="w-[28px] h-[28px]"
title={t("common.resetSidebarWidth")}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={onClose}
className="w-[28px] h-[28px]"
title={t("common.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent className="p-4 flex flex-col overflow-hidden">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex flex-col h-full overflow-hidden"
>
<TabsList className="w-full grid grid-cols-4 mb-4 flex-shrink-0">
<TabsTrigger value="ssh-tools">
{t("sshTools.title")}
</TabsTrigger>
<TabsTrigger value="snippets">
{t("snippets.title")}
</TabsTrigger>
<TabsTrigger value="command-history">
{t("commandHistory.title")}
</TabsTrigger>
<TabsTrigger value="split-screen">
{t("splitScreen.title")}
</TabsTrigger>
</TabsList>
<TabsContent value="ssh-tools" className="space-y-4">
<h3 className="font-semibold text-white">
{t("sshTools.keyRecording")}
</h3>
<div className="space-y-4">
<div className="flex gap-2">
{!isRecording ? (
<Button
onClick={handleStartRecording}
className="flex-1"
variant="outline"
>
{t("sshTools.startKeyRecording")}
</Button>
) : (
<Button
onClick={handleStopRecording}
className="flex-1"
variant="destructive"
>
{t("sshTools.stopKeyRecording")}
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("sshTools.selectTerminals")}
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("sshTools.typeCommands")}
</label>
<Input
id="ssh-tools-input"
placeholder={t("placeholders.typeHere")}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
{t("sshTools.commandsWillBeSent", {
count: selectedTabIds.length,
})}
</p>
</div>
</>
)}
</div>
<Separator />
<h3 className="font-semibold text-white">
{t("sshTools.settings")}
</h3>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
checked={rightClickCopyPaste}
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none text-white cursor-pointer"
>
{t("sshTools.enableRightClickCopyPaste")}
</label>
</div>
</TabsContent>
<TabsContent
value="snippets"
className="space-y-4 flex flex-col flex-1 overflow-hidden"
>
<div className="flex-shrink-0 space-y-4">
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.selectTerminals")}
</label>
<p className="text-xs text-muted-foreground">
{selectedSnippetTabIds.length > 0
? t("snippets.executeOnSelected", {
count: selectedSnippetTabIds.length,
})
: t("snippets.executeOnCurrent")}
</p>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedSnippetTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
}`}
onClick={() => handleSnippetTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<Separator />
</>
)}
<div className="flex gap-2">
<Button
onClick={handleCreate}
className="flex-1"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
<Button
onClick={handleCreateFolder}
className="flex-1"
variant="outline"
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("snippets.newFolder")}
</Button>
</div>
</div>
{loading ? (
<div className="text-center text-muted-foreground py-8 flex-1">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 && snippetFolders.length === 0 ? (
<div className="text-center text-muted-foreground py-8 flex-1">
<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 overflow-y-auto flex-1 min-h-0">
{Array.from(groupSnippetsByFolder()).map(
([folderName, folderSnippets]) => {
const folderMetadata = snippetFolders.find(
(f) => f.name === folderName,
);
const isCollapsed =
collapsedFolders.has(folderName);
return (
<div key={folderName || "uncategorized"}>
<div className="flex items-center gap-2 mb-2 hover:bg-dark-hover-alt p-2 rounded-lg transition-colors group/folder">
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => toggleFolder(folderName)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
{(() => {
const FolderIcon =
getFolderIcon(folderName);
const folderColor =
getFolderColor(folderName);
return (
<FolderIcon
className="h-4 w-4"
style={{
color: folderColor || undefined,
}}
/>
);
})()}
<span
className="text-sm font-semibold"
style={{
color:
getFolderColor(folderName) ||
undefined,
}}
>
{folderName ||
t("snippets.uncategorized", {
defaultValue: "Uncategorized",
})}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{folderSnippets.length}
</span>
</div>
{folderName && (
<div className="flex items-center gap-1 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditFolder(
folderMetadata || {
id: 0,
userId: "",
name: folderName,
createdAt: "",
updatedAt: "",
},
);
}}
>
<Settings className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folderName);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
{!isCollapsed && (
<div className="space-y-2 ml-6">
{folderSnippets.map((snippet) => (
<div
key={snippet.id}
draggable
onDragStart={(e) =>
handleDragStart(e, snippet)
}
onDragOver={(e) =>
handleDragOver(e, snippet)
}
onDrop={(e) => handleDrop(e, snippet)}
onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-move hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group ${
draggedSnippet?.id === snippet.id
? "opacity-50"
: ""
}`}
>
<div className="mb-2 flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
<div className="flex-1 min-w-0">
<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>
)}
<p className="text-xs text-muted-foreground mt-1">
ID: {snippet.id}
</p>
</div>
</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>
)}
</div>
);
},
)}
</div>
</TooltipProvider>
)}
</TabsContent>
<TabsContent
value="command-history"
className="flex flex-col flex-1 overflow-hidden"
>
<div className="space-y-2 flex-shrink-0 mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("commandHistory.searchPlaceholder")}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
{t("commandHistory.tabHint")}
</p>
</div>
<div className="flex-1 overflow-hidden min-h-0">
{historyError ? (
<div className="text-center py-8">
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
<p className="text-destructive font-medium mb-2">
{t("commandHistory.error")}
</p>
<p className="text-sm text-muted-foreground">
{historyError}
</p>
</div>
<Button
onClick={() =>
setHistoryRefreshCounter((prev) => prev + 1)
}
variant="outline"
>
{t("common.retry")}
</Button>
</div>
) : !activeTerminal ? (
<div className="text-center text-muted-foreground py-8">
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noTerminal")} </p>
<p className="text-sm">
{t("commandHistory.noTerminalHint")}
</p>
</div>
) : isHistoryLoading && commandHistory.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
<p className="mb-2 font-medium">
{t("commandHistory.loading")} </p>
</div>
) : filteredCommands.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noResults")}
</p>
<p className="text-sm">
{t("commandHistory.noResultsHint", {
query: searchQuery,
})}
</p>
</>
) : (
<>
<p className="mb-2 font-medium">
{t("commandHistory.empty")}
</p>
<p className="text-sm">
{t("commandHistory.emptyHint")}
</p>
</>
)}
</div>
) : (
<div
ref={commandHistoryScrollRef}
className="space-y-2 overflow-y-auto h-full"
>
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-dark-bg border-2 border-dark-border rounded-md px-3 py-2.5 hover:bg-dark-hover-alt hover:border-gray-600 transition-all duration-200 group h-12 flex items-center"
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span
className="flex-1 font-mono text-sm cursor-pointer text-white truncate"
onClick={() => handleCommandSelect(command)}
title={command}
>
{command}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCommandDelete(command);
}}
title={t("commandHistory.deleteTooltip")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</TabsContent>
<TabsContent
value="split-screen"
className="flex flex-col flex-1 overflow-hidden"
>
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<Tabs
value={splitMode}
onValueChange={(value) =>
handleSplitModeChange(
value as "none" | "2" | "3" | "4",
)
}
className="w-full"
>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="none">
{t("splitScreen.none")}
</TabsTrigger>
<TabsTrigger value="2">
{t("splitScreen.twoSplit")} </TabsTrigger>
<TabsTrigger value="3">
{t("splitScreen.threeSplit")} </TabsTrigger>
<TabsTrigger value="4">
{t("splitScreen.fourSplit")} </TabsTrigger>
</TabsList>
</Tabs>
{splitMode !== "none" && (
<>
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.availableTabs")}
</label>
<p className="text-xs text-muted-foreground mb-2">
{t("splitScreen.dragTabsHint")}
</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{splittableTabs.map((tab) => {
const isAssigned = Array.from(
splitAssignments.values(),
).includes(tab.id);
const isDragging = draggedTabId === tab.id;
return (
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() =>
handleTabDragStart(tab.id)
}
onDragEnd={handleTabDragEnd}
className={`
px-3 py-2 rounded-md text-sm cursor-move transition-all
${
isAssigned
? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50"
: "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input"
}
${isDragging ? "opacity-50" : ""}
`}
>
<span className="truncate">
{tab.title}
</span>
</div>
);
})}
</div>
</div>
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.layout")}
</label>
<div
className={`grid gap-2 ${
splitMode === "2"
? "grid-cols-2"
: splitMode === "3"
? "grid-cols-2 grid-rows-2"
: "grid-cols-2 grid-rows-2"
}`}
>
{Array.from(
{ length: parseInt(splitMode) },
(_, idx) => {
const assignedTabId =
splitAssignments.get(idx);
const assignedTab = assignedTabId
? tabs.find((t) => t.id === assignedTabId)
: null;
const isHovered = dragOverCellIndex === idx;
const isEmpty = !assignedTabId;
return (
<div
key={idx}
onDragOver={(e) =>
handleTabDragOver(e, idx)
}
onDragLeave={handleTabDragLeave}
onDrop={() => handleTabDrop(idx)}
className={`
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
flex flex-col items-center justify-center transition-all
${splitMode === "3" && idx === 2 ? "col-span-2" : ""}
${
isEmpty
? "border-dashed border-dark-border"
: "border-solid border-gray-400 bg-gray-500/10"
}
${
isHovered && draggedTabId
? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50"
: ""
}
`}
>
{assignedTab ? (
<>
<span className="text-sm text-white truncate w-full text-center mb-2">
{assignedTab.title}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveFromCell(idx)
}
className="h-6 text-xs hover:bg-red-500/20"
>
{t("common.remove")}
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("splitScreen.dropHere")}
</span>
)}
</div>
);
},
)}
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
onClick={handleApplySplit}
className="flex-1"
disabled={splitAssignments.size === 0}
>
{t("splitScreen.apply")}
</Button>
<Button
variant="outline"
onClick={handleClearSplit}
className="flex-1"
>
{t("splitScreen.clear")}
</Button>
</div>
</>
)}
{splitMode === "none" && (
<div className="text-center py-8">
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="text-sm text-muted-foreground mb-2">
{t("splitScreen.selectMode")}
</p>
<p className="text-xs text-muted-foreground">
{t("splitScreen.helpText")}
</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</SidebarContent>
{isOpen && (
<div
className="absolute top-0 h-full cursor-col-resize z-[60]"
onMouseDown={handleMouseDown}
style={{
left: "-8px",
width: "18px",
backgroundColor: isResizing
? "var(--dark-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--dark-border-hover)";
}
}}
onMouseLeave={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
title={t("common.dragToResizeSidebar")}
/>
)}
</Sidebar>
</SidebarProvider>
</div>
)}
{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-2">
<Folder className="h-4 w-4" />
{t("snippets.folder")}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Select
value={formData.folder || "__no_folder__"}
onValueChange={(value) =>
setFormData({
...formData,
folder: value === "__no_folder__" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue
placeholder={t("snippets.selectFolder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__no_folder__">
{t("snippets.noFolder")}
</SelectItem>
{snippetFolders.map((folder) => {
const FolderIcon = getFolderIcon(folder.name);
return (
<SelectItem key={folder.id} value={folder.name}>
<div className="flex items-center gap-2">
<FolderIcon
className="h-4 w-4"
style={{
color: folder.color || undefined,
}}
/>
<span>{folder.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</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("snippets.edit") : t("snippets.create")}
</Button>
</div>
</div>
</div>
)}
{showFolderDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowFolderDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
{editingFolder
? t("snippets.editFolder")
: t("snippets.createFolder")
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingFolder
? t("snippets.editFolderDescription")
: t("snippets.createFolderDescription")}
</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.folderName")}
<span className="text-destructive">*</span>
</label>
<Input
value={folderFormData.name}
onChange={(e) =>
setFolderFormData({
...folderFormData,
name: e.target.value,
})
}
placeholder={t("sshTools.scripts.inputPlaceholder")}
className={`${folderFormErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{folderFormErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.folderNameRequired")}
</p>
)}
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderColor")}
</Label>
<div className="grid grid-cols-4 gap-3">
{AVAILABLE_COLORS.map((color) => (
<button
key={color.value}
type="button"
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
folderFormData.color === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
}`}
style={{ backgroundColor: color.value }}
onClick={() =>
setFolderFormData({
...folderFormData,
color: color.value,
})
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderIcon")}
</Label>
<div className="grid grid-cols-5 gap-3">
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
<button
key={value}
type="button"
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
folderFormData.icon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
}`}
onClick={() =>
setFolderFormData({ ...folderFormData, icon: value })
}
title={label}
>
<Icon className="w-6 h-6" />
</button>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
{(() => {
const IconComponent =
AVAILABLE_ICONS.find(
(i) => i.value === folderFormData.icon,
)?.Icon || Folder;
return (
<IconComponent
className="w-5 h-5"
style={{ color: folderFormData.color }}
/>
);
})()}
<span className="font-medium">
{folderFormData.name ||
t("snippets.folderName")}
</span>
</div>
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowFolderDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleFolderSubmit} className="flex-1">
{editingFolder
? t("snippets.updateFolder")
: t("snippets.createFolder")
</Button>
</div>
</div>
</div>
)}
</>
);
}