Files
Termix/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
T
2768f11dfc v2.2.0 (#738)
* Improve Docker container list UI

* Rework SSH tunnel forwarding

* Update macOS Electron packaging

* Optimize frontend bundle splitting

* Add beta version update status

* Add client tunnel preset management

* Secure cookie authentication flows

* Add client tunnel bridge support

* Preserve sessions on restart

* Update runtime to Node 24

* Add client remote tunnel support

* Fix stale frontend cache handling

* Fix Docker image platforms for Node 24

* Fix Electron packaging workflows

* Fix client auth cache after upgrades

* chore: cleanup files

* fix: npm i error

* Fix OIDC auth cookie readiness

* Fix Docker npm ci config

* Add react-is peer dependency

* Fix Electron auth and cache handling

* Improve terminal clipboard and refresh actions

* feat: add API keys

* feat: improve lazy loading with loading spinners

* feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735)

* feat: integrate FolderTree component with lazy-loading for file manager sidebar

- Add motion animation library (v12.38.0) for smooth UI transitions
- Create new FolderTree component with advanced keyboard navigation support
- Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents
- Implement lazy-loading strategy for directory tree in FileManagerSidebar
- Refactor FileManagerSidebar with improved code organization and better separation of concerns
- Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard
- Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json

BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead.

- Improves UX with smooth animations and better folder navigation
- Reduces initial load time through lazy-loading subdirectories
- Enhances accessibility with ARIA labels and keyboard navigation
- Maintains dark mode support and proper styling

* fix: incorrect use of the theme system and linked file manger sidebar with current folder

---------

Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733)

* Fix Docker build info generation

* Remove unused node-fetch dependency

* feat: prompt user for SSH key passphrase on use (#715)

When an encrypted SSH key has no stored passphrase, show a lightweight
dialog prompting the user to enter it at connection time instead of
failing with a parse error. Supports both desktop and mobile terminals.

Closes Termix-SSH/Support#354

* fix: prevent session crash when uploading to permission-denied directory (#716)

- Wrap writeFile sftp.stat callback in try-catch to prevent uncaught
  exceptions from escaping the callback into the event loop
- Add missing stream.stderr error handler in writeFile fallback to
  prevent unhandled error events from crashing the process
- Remove bogus activeOperations decrement in both writeFile and
  uploadFile fallback methods (counter was never incremented)
- Add res.headersSent checks in fallback disconnect paths to prevent
  ERR_HTTP_HEADERS_SENT crashes

Closes Termix-SSH/Support#652

* feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718)

Support LOG_TIMESTAMP_FORMAT environment variable with values:
- "24h": 24-hour format (14:58:45)
- "iso": ISO 8601 format (2026-04-25T14:58:45.000Z)
- default: locale format (2:58:45 PM)

Closes Termix-SSH/Support#650

* feat: open file manager at terminal current working directory (#719)

* feat: open file manager at terminal current working directory

When right-clicking in the terminal and selecting "Open File Manager
Here", query the current working directory via a separate SSH exec
channel and pass it as the initial path to the file manager tab.

Closes Termix-SSH/Support#649

* chore: sync package-lock.json with node-fetch and deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove undefined TerminalContextMenu from bad merge resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: show reconnect overlay when SSH server reboots (#720)

When the remote server reboots, the SSH connection closes while the
stream is still active. The close handler only sent the "disconnected"
message when sshStream was null, so the frontend never received the
disconnect notification and hung with a blinking cursor.

Change the else-if condition to always send the "disconnected" message
regardless of stream state.

Closes Termix-SSH/Support#648

* feat: support read-only Docker container mode (#721)

Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/
to /tmp/nginx/ so the container can run with read_only: true. Template
files remain in /app/nginx/ as read-only assets.

Users can now harden the container with:
  read_only: true
  tmpfs:
    - /tmp

Closes Termix-SSH/Support#647

* fix: allow editing host folder without re-entering password (#722)

When editing an existing host, the password field is stripped by the
backend for security. The form validation treated the empty password
as invalid, disabling the Update Host button even for non-auth changes
like folder assignment.

Use an "existing_password" sentinel (mirroring the existing
"existing_key" pattern) to represent an unchanged password during
editing, skip validation for it, and omit it from the update payload.

Closes Termix-SSH/Support#645

* fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723)

Distinguish between graceful shell exit and unexpected disconnection
using the stream close event's exit code. When the shell exits normally
(code != null), send "session_ended" instead of "disconnected". The
frontend auto-closes the tab on session_ended, and shows the reconnect
overlay only on unexpected disconnections.

Closes Termix-SSH/Support#643

* fix: reattach existing SSH session on WebSocket reconnect (#724)

WebSocket reconnection was always creating a new SSH connection with
full authentication instead of reattaching to the existing SSH session.
The condition `!isReconnectingRef.current` prevented session reattach
during reconnection, causing repeated password auth attempts that
trigger SSHGuard/fail2ban blocking.

Remove the guard so reconnection tries to reattach the persisted
session first. If the session has expired, the backend sends
sessionExpired and the frontend falls back to a new connection.

Closes Termix-SSH/Support#644

* fix: prevent browser crash when uploading large files (>100MB) (#725)

The file-to-base64 conversion used a byte-by-byte string concatenation
loop (String.fromCharCode + btoa), which allocated ~3x the file size
in intermediate strings, causing the browser tab to OOM on files over
~100MB.

Replace with FileReader.readAsDataURL which delegates base64 encoding
to the browser engine natively, avoiding the intermediate allocations.

Closes Termix-SSH/Support#577

* fix: support SSH multi-factor auth with publickey + password (#726)

When sshd requires AuthenticationMethods publickey,password, the
connection failed because the key auth branch only set privateKey
without also setting password. After publickey partial auth succeeded,
ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of
password, which the server rejected.

Pass the credential password alongside the private key so ssh2 can
complete the password step after publickey succeeds.

Closes Termix-SSH/Support#629

* feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727)

The `allow_registration` setting blocks both password-based and OIDC user
creation. Admins who want to close password registration but still onboard
new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist)
have no way to do that today.

Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC
callback skips the `allow_registration` settings check while still honoring
the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST
/users/create` continues to respect `allow_registration`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: lazy load locales, file previews, and decouple startup imports (#729)

* perf: lazy load locale bundles

* perf: lazy load file preview modules

* perf: avoid eager api client load on startup

* chore: remove dead code, tighten types, fix lint warnings (#730)

* chore: clean up low-risk lint warnings

* chore: tighten utility types

* chore: preserve backend error causes

* chore: simplify command palette host state

* chore: remove unused frontend code

* chore: prune stale frontend state

* chore: trim unused navigation code

* chore: prune unused user settings props

* chore: trim unused sidebar state

* chore: remove stale host editor imports

* chore: tighten shared frontend types

* chore: narrow desktop helper types

* chore: type network topology data

* chore: type connection log errors

* chore: use typed tab context

* chore: type api client error metadata

* chore: tighten terminal config types

* chore: type host proxy chains

* chore: type host editor form data

* chore: use typed host viewer fields

* chore: format app builder patch script

* Fix client auth cache after upgrades

* chore: fix pr checks after dev merge

* fix: remove duplicate session-expired useEffect in FullScreenAppWrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: npm package warnings

* feat: reconnect after file manager disconnects

* feat: add docs button in api keys

* feat: change colors for server tunnels

* fix: fetch password from API for Copy Password button (#736)

* chore: update readme's

* feat: improve c2s UI in user profile

* feat: improve ssh key detection and move open file manager at path for terminal button

* fix: restore missing getHostPassword import in Tab.tsx (#737)

* fix: security related fixes

* feat: improve alert code

* Fix Electron clipboard handling

* fix: untranslated alert text

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com>
Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Fuad <funtik1229@yandex.ru>
2026-05-06 15:12:07 -05:00

2602 lines
100 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,
Search,
Loader2,
Terminal,
LayoutGrid,
Folder,
ChevronDown,
ChevronRight,
GripVertical,
FolderPlus,
Settings,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
Share2,
} 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,
getSharedSnippets,
shareSnippet,
getSnippetAccess,
revokeSnippetAccess,
getRoles,
getUserList,
type AccessRecord,
} 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") !== "false",
);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
const [sharedSnippetsList, setSharedSnippetsList] = useState<
Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
}>
>([]);
const [shareDialogSnippet, setShareDialogSnippet] = useState<Snippet | null>(
null,
);
const [shareTargetType, setShareTargetType] = useState<"user" | "role">(
"user",
);
const [shareUsers, setShareUsers] = useState<
Array<{ id: string; username: string }>
>([]);
const [shareRoles, setShareRoles] = useState<
Array<{ id: number; name: string; displayName?: string }>
>([]);
const [shareTargetId, setShareTargetId] = useState("");
const [shareAccessList, setShareAccessList] = useState<AccessRecord[]>([]);
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 [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set();
});
const [snippetSearchQuery, setSnippetSearchQuery] = useState("");
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 [commandHistoryEnabled, setCommandHistoryEnabled] = useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
const [splitMode, setSplitMode] = useState<
"none" | "2" | "3" | "4" | "5" | "6"
>("none");
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
new Map(),
);
const [, 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");
useEffect(() => {
const terminalIds = new Set(terminalTabs.map((t) => t.id));
setSelectedSnippetTabIds((prev) =>
prev.filter((id) => terminalIds.has(id)),
);
}, [terminalTabs.length]);
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_stats" ||
tab.type === "file_manager" ||
tab.type === "tunnel" ||
tab.type === "docker" ||
tab.type === "rdp" ||
tab.type === "vnc" ||
tab.type === "telnet",
);
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]);
useEffect(() => {
const handleChange = () => {
setCommandHistoryEnabled(
localStorage.getItem("commandHistoryTracking") === "true",
);
};
window.addEventListener("commandHistoryTrackingChanged", handleChange);
return () =>
window.removeEventListener("commandHistoryTrackingChanged", handleChange);
}, []);
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, sharedData] = await Promise.all([
getSnippets(),
getSnippetFolders(),
getSharedSnippets().catch(() => ({ sharedSnippets: [] })),
]);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
setSharedSnippetsList(sharedData.sharedSnippets || []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
setSnippetFolders([]);
setSharedSnippetsList([]);
} 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 handleOpenShareDialog = async (snippet: Snippet) => {
setShareDialogSnippet(snippet);
setShareTargetId("");
setShareTargetType("user");
try {
const [usersData, rolesData, accessData] = await Promise.all([
getUserList(),
getRoles(),
getSnippetAccess(snippet.id),
]);
setShareUsers(
(usersData?.users || []).map((u: Record<string, unknown>) => ({
id: u.id as string,
username: u.username as string,
})),
);
setShareRoles(
(rolesData?.roles || []).map(
(r: { id: number; name: string; displayName?: string }) => r,
),
);
setShareAccessList(accessData.accessList || []);
} catch {
toast.error(t("snippets.failedToLoadShareData"));
}
};
const handleShare = async () => {
if (!shareDialogSnippet || !shareTargetId) return;
try {
await shareSnippet(shareDialogSnippet.id, {
targetType: shareTargetType,
targetUserId: shareTargetType === "user" ? shareTargetId : undefined,
targetRoleId:
shareTargetType === "role" ? parseInt(shareTargetId) : undefined,
});
toast.success(t("snippets.shareSuccess"));
const accessData = await getSnippetAccess(shareDialogSnippet.id);
setShareAccessList(accessData.accessList || []);
setShareTargetId("");
} catch {
toast.error(t("snippets.shareFailed"));
}
};
const handleRevokeSnippetAccess = async (accessId: number) => {
if (!shareDialogSnippet) return;
try {
await revokeSnippetAccess(shareDialogSnippet.id, accessId);
toast.success(t("snippets.revokeSuccess"));
const accessData = await getSnippetAccess(shareDialogSnippet.id);
setShareAccessList(accessData.accessList || []);
} catch {
toast.error(t("snippets.revokeFailed"));
}
};
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 = async (snippet: Snippet) => {
const confirmEnabled =
localStorage.getItem("confirmSnippetExecution") === "true";
if (confirmEnabled) {
const confirmed = await confirmWithToast(
t("snippets.confirmExecution", { name: snippet.name }),
undefined,
"default",
t("common.cancel"),
{ confirmOnEnter: true, duration: 8000 },
);
if (!confirmed) {
return;
}
}
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 + "\r");
}
});
toast.success(
t("snippets.executeSuccess", {
name: snippet.name,
count: selectedSnippetTabIds.length,
}),
);
} else {
onSnippetExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
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, []);
}
});
const filteredSnippets = snippetSearchQuery
? snippets.filter(
(snippet) =>
snippet.name
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.content
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.description
?.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()),
)
: snippets;
filteredSnippets.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) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = async (e: React.DragEvent, targetSnippet: Snippet) => {
e.preventDefault();
if (!draggedSnippet || draggedSnippet.id === targetSnippet.id) {
setDraggedSnippet(null);
return;
}
const sourceFolder = draggedSnippet.folder || "";
const targetFolder = targetSnippet.folder || "";
if (sourceFolder !== targetFolder) {
toast.error(t("snippets.reorderSameFolder"));
setDraggedSnippet(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);
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);
};
const handleDragEnd = () => {
setDraggedSnippet(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" | "5" | "6",
) => {
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 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-foreground">
{t("nav.tools")}
<div className="absolute right-5 flex gap-1">
<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-foreground">
{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-foreground">
{t("sshTools.selectTerminals")}
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
{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-foreground bg-surface"
: "text-foreground-subtle"
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{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-foreground">
{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-foreground 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-foreground">
{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 gap-2 mb-1">
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() =>
setSelectedSnippetTabIds(
terminalTabs.map((t) => t.id),
)
}
>
{t("snippets.selectAll", "Select All")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => setSelectedSnippetTabIds([])}
>
{t("snippets.deselectAll", "Deselect All")}
</Button>
</div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
{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-foreground bg-surface"
: "text-foreground-subtle"
}`}
onClick={() => handleSnippetTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<Separator />
</>
)}
<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("snippets.searchSnippets")}
value={snippetSearchQuery}
onChange={(e) => {
setSnippetSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{snippetSearchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSnippetSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<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 thin-scrollbar">
{sharedSnippetsList.length > 0 &&
(() => {
const isCollapsed =
collapsedFolders.has("__shared__");
const FolderIcon = getFolderIcon("__shared__");
return (
<div key="__shared__">
<div className="flex items-center gap-2 mb-2 hover:bg-hover-alt p-2 rounded-lg transition-colors group/folder">
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => toggleFolder("__shared__")}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<FolderIcon className="h-4 w-4" />
<span className="text-sm font-semibold">
{t("snippets.sharedWithYou")}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{sharedSnippetsList.length}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 pointer-events-none">
<div className="h-6 w-6" />
<div className="h-6 w-6" />
</div>
</div>
{!isCollapsed && (
<div className="space-y-2 ml-6">
{sharedSnippetsList.map((snippet) => (
<div
key={`shared-${snippet.id}`}
className="bg-field border border-input rounded-lg hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-foreground 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">
{t("snippets.sharedBy", {
username:
snippet.ownerUsername,
})}
</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 as unknown as 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 as unknown as Snippet,
)
}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("snippets.copyTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
);
})()}
{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-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={handleDragOver}
onDrop={(e) => handleDrop(e, snippet)}
onDragEnd={handleDragEnd}
className={`bg-field border border-input rounded-lg cursor-move hover:shadow-lg hover:border-edge-hover hover:bg-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-foreground 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>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() =>
handleOpenShareDialog(
snippet,
)
}
>
<Share2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("snippets.shareTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
);
},
)}
</div>
</TooltipProvider>
)}
</TabsContent>
<TabsContent
value="command-history"
className="flex flex-col flex-1 overflow-hidden"
>
{!commandHistoryEnabled ? (
<div className="flex flex-col items-center justify-center flex-1 py-8 text-center px-4">
<div className="bg-muted/40 border rounded-lg p-5 space-y-2">
<p className="font-medium text-sm">
{t("commandHistory.disabledTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("commandHistory.disabledDescription")}
</p>
</div>
</div>
) : (
<>
<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 thin-scrollbar"
>
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover 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-foreground 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 thin-scrollbar">
<Tabs
value={splitMode}
onValueChange={(value) =>
handleSplitModeChange(
value as "none" | "2" | "3" | "4" | "5" | "6",
)
}
className="w-full"
>
<TabsList className="w-full grid grid-cols-3 grid-rows-2 h-auto gap-2 p-2">
<TabsTrigger value="none" className="h-10">
{t("splitScreen.none")}
</TabsTrigger>
<TabsTrigger value="2" className="h-10">
{t("splitScreen.twoSplit")}
</TabsTrigger>
<TabsTrigger value="3" className="h-10">
{t("splitScreen.threeSplit")}
</TabsTrigger>
<TabsTrigger value="4" className="h-10">
{t("splitScreen.fourSplit")}
</TabsTrigger>
<TabsTrigger value="5" className="h-10">
{t("splitScreen.fiveSplit")}
</TabsTrigger>
<TabsTrigger value="6" className="h-10">
{t("splitScreen.sixSplit")}
</TabsTrigger>
</TabsList>
</Tabs>
{splitMode !== "none" && (
<>
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{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 thin-scrollbar">
{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-canvas/50 text-muted-foreground cursor-not-allowed opacity-50"
: "bg-canvas border border-edge hover:border-edge-hover hover:bg-field"
}
${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-foreground">
{t("splitScreen.layout")}
</label>
<div
className={`grid gap-2 mt-2 ${
splitMode === "2"
? "grid-cols-2"
: splitMode === "5" || splitMode === "6"
? "grid-cols-3 grid-rows-2"
: "grid-cols-2 grid-rows-2"
}`}
>
{Array.from(
{ length: parseInt(splitMode) },
(_, idx) => {
const assignedTabId =
splitAssignments.get(idx);
const assignedTab = assignedTabId
? splittableTabs.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-canvas 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-edge"
: "border-solid border-edge-hover bg-surface"
}
${
isHovered && draggedTabId
? "border-edge-hover bg-surface ring-2 ring-edge-hover"
: ""
}
`}
>
{assignedTab ? (
<>
<span className="text-sm text-foreground 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: "-4px",
width: "8px",
backgroundColor: isResizing
? "var(--bg-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--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-canvas border-2 border-edge rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto thin-scrollbar"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-foreground">
{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-foreground 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-foreground">
{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-foreground 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-foreground 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-canvas border-2 border-edge 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-foreground">
{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-foreground 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-foreground">
{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-edge"
}`}
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-foreground">
{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-edge bg-elevated"
}`}
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-foreground">
{t("snippets.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
{(() => {
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>
)}
{shareDialogSnippet && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-card border-2 border-border rounded-lg p-6 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("snippets.shareSnippet")}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setShareDialogSnippet(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{shareDialogSnippet.name}
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Select
value={shareTargetType}
onValueChange={(v: "user" | "role") => {
setShareTargetType(v);
setShareTargetId("");
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("snippets.user")}</SelectItem>
<SelectItem value="role">{t("snippets.role")}</SelectItem>
</SelectContent>
</Select>
<Select value={shareTargetId} onValueChange={setShareTargetId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={t("snippets.selectTarget")} />
</SelectTrigger>
<SelectContent>
{shareTargetType === "user"
? shareUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.username}
</SelectItem>
))
: shareRoles.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.displayName || r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleShare}
disabled={!shareTargetId}
size="sm"
>
<Share2 className="w-4 h-4" />
</Button>
</div>
</div>
{shareAccessList.length > 0 && (
<div className="space-y-2">
<span className="text-sm font-semibold">
{t("snippets.currentAccess")}
</span>
{shareAccessList.map((access) => (
<div
key={access.id}
className="flex items-center justify-between rounded-md border p-2 text-sm"
>
<span>
{access.username ||
access.roleDisplayName ||
access.roleName}
</span>
<Button
size="sm"
variant="ghost"
className="hover:bg-destructive hover:text-destructive-foreground"
onClick={() => handleRevokeSnippetAccess(access.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</>
);
}