mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-18 14:23:35 +00:00
v2.3.0
This commit is contained in:
+1179
-291
File diff suppressed because it is too large
Load Diff
+75
-24
@@ -1,9 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
Network,
|
||||
Play,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -16,10 +18,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { SplitMode, ToolsTab } from "@/types/ui-types";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
| "credentials"
|
||||
| "quick-connect"
|
||||
| ToolsTab
|
||||
| "user-profile"
|
||||
@@ -33,27 +36,47 @@ type RailItem =
|
||||
title: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(splitMode: SplitMode): RailItem[] {
|
||||
function buildRailButtons(
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
): RailItem[] {
|
||||
return [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: "Hosts" },
|
||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||
{
|
||||
view: "credentials",
|
||||
icon: <KeyRound size={16} />,
|
||||
title: t("nav.credentials"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ view: "quick-connect", icon: <Zap size={16} />, title: "Quick Connect" },
|
||||
{
|
||||
view: "quick-connect",
|
||||
icon: <Zap size={16} />,
|
||||
title: t("nav.quickConnect"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: "SSH Tools" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
|
||||
{ kind: "separator" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: "Snippets" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
|
||||
{ kind: "separator" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: "History" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: t("nav.history") },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "split-screen",
|
||||
icon: <LayoutPanelLeft size={16} />,
|
||||
title: "Split Screen",
|
||||
title: t("nav.splitScreen"),
|
||||
dot: splitMode !== "none",
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "tab",
|
||||
tabType: "network_graph" as TabType,
|
||||
icon: <Network size={16} />,
|
||||
title: t("nav.networkGraph"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -66,23 +89,28 @@ export function AppRail({
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
username,
|
||||
isAdmin,
|
||||
profileDropdownOpen,
|
||||
onProfileDropdownChange,
|
||||
onRailClick,
|
||||
onOpenTab,
|
||||
onLogout,
|
||||
}: {
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
profileDropdownOpen: boolean;
|
||||
onProfileDropdownChange: (open: boolean) => void;
|
||||
onRailClick: (view: RailView) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const railExpanded = hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode);
|
||||
const railButtons = buildRailButtons(splitMode, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,6 +127,27 @@ export function AppRail({
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
/>
|
||||
) : item.kind === "tab" ? (
|
||||
<button
|
||||
key={item.tabType}
|
||||
onClick={() => onOpenTab?.(item.tabType)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.view}
|
||||
@@ -132,20 +181,22 @@ export function AppRail({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{(
|
||||
[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: "Profile",
|
||||
},
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: "Admin",
|
||||
},
|
||||
] as const
|
||||
).map((item) => (
|
||||
{[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: t("nav.userProfile"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: t("nav.admin"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(item.view)}
|
||||
@@ -196,7 +247,7 @@ export function AppRail({
|
||||
{username || "User"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
Administrator
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
|
||||
export function CredentialsPanel({
|
||||
onEditingChange,
|
||||
active = true,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
setManagerEditing(editing);
|
||||
onEditingChange?.(editing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{!managerEditing && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("credentials.searchCredentials")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-credential"),
|
||||
)
|
||||
}
|
||||
title={t("credentials.addCredential")}
|
||||
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
|
||||
>
|
||||
<Plus className="size-3 shrink-0" />
|
||||
{t("credentials.addCredential")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<HostManager
|
||||
hideListHeader
|
||||
externalSearch={managerEditing ? undefined : search}
|
||||
onEditingChange={handleEditingChange}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
|
||||
import { getCommandHistory, deleteCommandFromHistory } from "@/main-axios";
|
||||
import {
|
||||
getCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
clearCommandHistory,
|
||||
} from "@/main-axios";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
export function HistoryPanel({
|
||||
@@ -16,20 +20,51 @@ export function HistoryPanel({
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commands, setCommands] = useState<string[]>([]);
|
||||
const [trackingEnabled, setTrackingEnabled] = useState(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
const activeTab = terminalTabs.find((t) => t.id === activeTabId);
|
||||
const activeIsTerminal = !!activeTab;
|
||||
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
const handler = () =>
|
||||
setTrackingEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
window.addEventListener("commandHistoryTrackingChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("commandHistoryTrackingChanged", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId || !trackingEnabled) {
|
||||
setCommands([]);
|
||||
return;
|
||||
}
|
||||
getCommandHistory(hostId)
|
||||
.then(setCommands)
|
||||
.catch(() => setCommands([]));
|
||||
}, [hostId]);
|
||||
}, [hostId, trackingEnabled]);
|
||||
|
||||
if (activeIsTerminal && !trackingEnabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("newUi.sidebar.history.trackingDisabled")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("newUi.sidebar.history.trackingDisabledHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeIsTerminal) {
|
||||
return (
|
||||
@@ -85,7 +120,15 @@ export function HistoryPanel({
|
||||
{filtered.length} command{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommands([])}
|
||||
onClick={async () => {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await clearCommandHistory(hostId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCommands([]);
|
||||
}}
|
||||
className="text-xs text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.history.clearAll")}
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Status, StatusIndicator } from "@/components/shadcn-io/status";
|
||||
import { Button } from "@/components/button";
|
||||
import { ButtonGroup } from "@/components/button-group";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
Server,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { useTabs } from "@/shell/TabContext";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
wakeOnLan,
|
||||
} from "@/main-axios";
|
||||
import type { HostProps } from "@/types";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostStatus } from "@/lib/ServerStatusContext";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
const [host, setHost] = useState(initialHost);
|
||||
const { t } = useTranslation();
|
||||
const [showTags, setShowTags] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
setHost(initialHost);
|
||||
}, [initialHost]);
|
||||
|
||||
const hostIdRef = React.useRef(host.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
hostIdRef.current = host.id;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
|
||||
if (updatedHost) {
|
||||
setHost(updatedHost);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowTagsChanged = () => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
setShowTags(saved !== null ? saved === "true" : true);
|
||||
};
|
||||
|
||||
window.addEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
}, []);
|
||||
|
||||
const statsConfig = useMemo(() => {
|
||||
if (!host.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
if (typeof host.statsConfig === "object") {
|
||||
return host.statsConfig;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [host.statsConfig]);
|
||||
const shouldShowStatus = ![false, "false"].includes(
|
||||
statsConfig.statusCheckEnabled,
|
||||
);
|
||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
|
||||
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
||||
|
||||
const hasTunnelConnections = useMemo(() => {
|
||||
if (!host.tunnelConnections) return false;
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections);
|
||||
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [host.tunnelConnections]);
|
||||
|
||||
const handleTerminalClick = async () => {
|
||||
if (
|
||||
host.connectionType === "rdp" ||
|
||||
host.connectionType === "vnc" ||
|
||||
host.connectionType === "telnet"
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol,
|
||||
type: protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity(protocol, host.id, title);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get Guacamole token:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
};
|
||||
|
||||
const isSSH = !host.connectionType || host.connectionType === "ssh";
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||
isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldShowStatus && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (host.showTerminalInSidebar ?? true) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare />
|
||||
) : (
|
||||
<Terminal />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<ArrowDownUp />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Container />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"!px-2 border-1 border-edge",
|
||||
visibleButtons > 0 && "rounded-l-none border-l-0",
|
||||
)}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
className="w-56 bg-canvas border-edge text-foreground"
|
||||
>
|
||||
{host.enableTerminal && !(host.showTerminalInSidebar ?? true) && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleTerminalClick}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">
|
||||
{host.connectionType === "rdp"
|
||||
? t("hosts.openRdp")
|
||||
: host.connectionType === "vnc"
|
||||
? t("hosts.openVnc")
|
||||
: host.connectionType === "telnet"
|
||||
? t("hosts.openTelnet")
|
||||
: t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
!(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(t("hosts.wolSent"));
|
||||
} catch {
|
||||
toast.error(t("hosts.wolFailed"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.wakeOnLan")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("nav.hostManager"),
|
||||
hostConfig: host,
|
||||
initialTab: "hosts",
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">{t("common.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{showTags && hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="bg-canvas border-1 border-edge pl-2 pr-2 rounded-[10px]"
|
||||
>
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3759
-2260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
getHostAccess,
|
||||
shareHost,
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
} from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
export function HostShareModal({
|
||||
open,
|
||||
onClose,
|
||||
host,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
host: Host | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [shareType, setShareType] = useState<"user" | "role">("user");
|
||||
const [shareGranteeId, setShareGranteeId] = useState("");
|
||||
const [shareExpiryHours, setShareExpiryHours] = useState("");
|
||||
const [accessList, setAccessList] = useState<any[]>([]);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
{ id: string; username: string }[]
|
||||
>([]);
|
||||
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !host) return;
|
||||
if (sharingLoaded) return;
|
||||
setSharingLoaded(true);
|
||||
Promise.all([
|
||||
getHostAccess(Number(host.id)).catch(() => ({ accessList: [] })),
|
||||
getUserList().catch(() => ({ users: [] })),
|
||||
getRoles().catch(() => ({ roles: [] })),
|
||||
])
|
||||
.then(([accessRes, usersRes, rolesRes]) => {
|
||||
setAccessList((accessRes as any)?.accessList ?? []);
|
||||
setShareUsers(
|
||||
((usersRes as any)?.users ?? []).map((u: any) => ({
|
||||
id: String(u.id ?? u.userId),
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
((rolesRes as any)?.roles ?? []).map((r: any) => ({
|
||||
id: String(r.id),
|
||||
name: r.name,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setSharingLoadError(true));
|
||||
}, [open, host, sharingLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
setSharingLoaded(false);
|
||||
setSharingLoadError(false);
|
||||
setAccessList([]);
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
setShareType("user");
|
||||
}, [host?.id]);
|
||||
|
||||
async function refreshAccessList() {
|
||||
if (!host) return;
|
||||
const res = await getHostAccess(Number(host.id));
|
||||
setAccessList((res as any)?.accessList ?? []);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hasCredential = !!host?.credentialId;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t("hosts.shareHostTitle", { name: host?.name ?? "" })}</span>
|
||||
</button>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3">
|
||||
{!hasCredential && host !== null && (
|
||||
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.sharing.requiresCredential")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharingLoadError && hasCredential && (
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.guac.sharingLoadError")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredential && (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.shareHostSection")}
|
||||
icon={<Users className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{(["user", "role"] as const).map((shareTypeOpt) => (
|
||||
<button
|
||||
key={shareTypeOpt}
|
||||
onClick={() => {
|
||||
setShareType(shareTypeOpt);
|
||||
setShareGranteeId("");
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{shareTypeOpt === "user" ? (
|
||||
<>
|
||||
<User className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithUser")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithRole")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUser")
|
||||
: t("hosts.guac.selectRole")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={shareGranteeId}
|
||||
onChange={(e) => setShareGranteeId(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUserOption")
|
||||
: t("hosts.guac.selectRoleOption")}
|
||||
</option>
|
||||
{shareType === "user"
|
||||
? shareUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))
|
||||
: shareRoles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.expiresInHours")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.guac.noExpiryPlaceholder")}
|
||||
value={shareExpiryHours}
|
||||
onChange={(e) => setShareExpiryHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!shareGranteeId}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await shareHost(Number(host!.id), {
|
||||
targetType: shareType,
|
||||
...(shareType === "user"
|
||||
? { targetUserId: shareGranteeId }
|
||||
: { targetRoleId: Number(shareGranteeId) }),
|
||||
permissionLevel: "view",
|
||||
...(shareExpiryHours
|
||||
? { durationHours: Number(shareExpiryHours) }
|
||||
: {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t("hosts.guac.shareBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.guac.currentAccess")}
|
||||
icon={<ListChecks className="size-3.5" />}
|
||||
>
|
||||
<div className="py-2">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.guac.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((r: any, i: number) => {
|
||||
const expired =
|
||||
r.expiresAt && new Date(r.expiresAt) < new Date();
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{r.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{r.username ??
|
||||
r.roleName ??
|
||||
r.roleDisplayName ??
|
||||
r.userId ??
|
||||
r.roleId}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize shrink-0">
|
||||
({r.targetType})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), r.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRevokeAccess"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.guac.revokeBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.guac.grantedByHeader")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{r.grantedByUsername ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.guac.expiresHeader")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.guac.expiredLabel")}
|
||||
</span>
|
||||
) : r.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(r.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.guac.neverLabel")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+305
-43
@@ -1,78 +1,340 @@
|
||||
import { useState } from "react";
|
||||
import { Search, Settings, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
ListChecks,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SidebarTree } from "@/sidebar/SidebarTree";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { getSSHHosts, bulkImportSSHHosts } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
export function HostsPanel({
|
||||
expanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
pendingEditId,
|
||||
pendingAction,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
hostTree,
|
||||
loading,
|
||||
onEditingChange,
|
||||
active = true,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
pendingEditId: MutableRefObject<string | null>;
|
||||
pendingAction: MutableRefObject<"add-host" | "add-credential" | null>;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
hostTree?: HostFolder;
|
||||
loading?: boolean;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [rawHosts, setRawHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [shareModalHost, setShareModalHost] = useState<Host | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const importOverwriteRef = useRef(false);
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<HostManager
|
||||
onCollapse={onCollapse}
|
||||
pendingEditId={pendingEditId}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then(setRawHosts)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
setManagerEditing(editing);
|
||||
onEditingChange?.(editing);
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
setSelectionMode((v) => !v);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
setRawHosts(hosts);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportHosts() {
|
||||
const data = JSON.stringify({ hosts: rawHosts }, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-hosts.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("hosts.hostsExported"));
|
||||
}
|
||||
|
||||
function handleDownloadSample() {
|
||||
const sample = JSON.stringify(
|
||||
{
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server (Production)",
|
||||
ip: "192.168.1.100",
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
notes: "Main production web server running Nginx",
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
defaultPath: "/var/www",
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([sample], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-hosts-sample.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{!managerEditing && (
|
||||
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={(e) => setHostSearch(e.target.value)}
|
||||
placeholder={t("hosts.searchHosts")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
{hostSearch && (
|
||||
<button
|
||||
onClick={() => setHostSearch("")}
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={(e) => setHostSearch(e.target.value)}
|
||||
placeholder="Search hosts..."
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
const hostsArray = Array.isArray(parsed)
|
||||
? parsed
|
||||
: (parsed.hosts ?? []);
|
||||
if (!Array.isArray(hostsArray) || hostsArray.length === 0) {
|
||||
toast.error("No hosts found in file");
|
||||
return;
|
||||
}
|
||||
if (hostsArray.length > 100) {
|
||||
toast.error("Cannot import more than 100 hosts at once");
|
||||
return;
|
||||
}
|
||||
const normalized = hostsArray.map((h: any) => ({
|
||||
...h,
|
||||
port: h.port ?? h.sshPort ?? 22,
|
||||
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
}));
|
||||
const result = await bulkImportSSHHosts(
|
||||
normalized,
|
||||
importOverwriteRef.current,
|
||||
);
|
||||
const hosts = await getSSHHosts();
|
||||
setRawHosts(hosts);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
const msg = [
|
||||
result.success ? `${result.success} imported` : null,
|
||||
result.updated ? `${result.updated} updated` : null,
|
||||
result.failed ? `${result.failed} failed` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
toast.success(`Import complete: ${msg}`);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Failed to import hosts");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{hostSearch && (
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
title={t("hosts.refreshBtn2")}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
title={t("hosts.importExportBtn")}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="text-xs">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
importOverwriteRef.current = false;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importSkipExisting")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
importOverwriteRef.current = true;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importOverwrite")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleExportHosts}
|
||||
disabled={rawHosts.length === 0}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.exportAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownloadSample}>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.downloadSample")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
title={
|
||||
selectionMode
|
||||
? t("hosts.exitSelectionTitle")
|
||||
: t("hosts.selectHosts")
|
||||
}
|
||||
onClick={toggleSelectionMode}
|
||||
className={`flex items-center justify-center size-7 rounded-sm shrink-0 transition-colors ${selectionMode ? "text-accent-brand bg-accent-brand/10 border border-accent-brand/30" : "text-muted-foreground/60 hover:text-foreground hover:bg-muted/60 border border-transparent"}`}
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHostSearch("")}
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-host"))
|
||||
}
|
||||
title={t("hosts.addHost")}
|
||||
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
<Plus className="size-3 shrink-0" />
|
||||
{t("hosts.addHost")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExpand}
|
||||
title="Manage Hosts"
|
||||
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 border border-border/60 rounded-sm shrink-0 transition-colors"
|
||||
>
|
||||
<Settings className="size-3 shrink-0" />
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1">
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-h-0 ${managerEditing ? "hidden" : ""}`}
|
||||
>
|
||||
<SidebarTree
|
||||
children={hostTree?.children ?? []}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={(host) => setShareModalHost(host)}
|
||||
query={hostSearch.trim().toLowerCase()}
|
||||
selectionMode={selectionMode}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
|
||||
>
|
||||
<HostManager onEditingChange={handleEditingChange} active={active} />
|
||||
</div>
|
||||
|
||||
<HostShareModal
|
||||
open={shareModalHost !== null}
|
||||
onClose={() => setShareModalHost(null)}
|
||||
host={shareModalHost}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
export function QuickConnectPanel() {
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: "terminal" | "files") => void;
|
||||
}
|
||||
|
||||
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
@@ -12,8 +17,45 @@ export function QuickConnectPanel() {
|
||||
"password",
|
||||
);
|
||||
const [password, setPassword] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const connect = (type: "terminal" | "files") => {
|
||||
if (!host || !username) return;
|
||||
const hostConfig: Host = {
|
||||
id: `quick-connect-${Date.now()}`,
|
||||
name: `${username}@${host}`,
|
||||
ip: host,
|
||||
port: parseInt(port) || 22,
|
||||
username,
|
||||
authType,
|
||||
password: authType === "password" ? password : undefined,
|
||||
key: authType === "key" ? privateKey : undefined,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
pin: false,
|
||||
defaultPath: "",
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
enableTerminal: true,
|
||||
enableFileManager: true,
|
||||
enableTunnel: true,
|
||||
enableDocker: true,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: parseInt(port) || 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
};
|
||||
onConnect(hostConfig, type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
@@ -25,6 +67,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,6 +81,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,6 +95,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,6 +134,9 @@ export function QuickConnectPanel() {
|
||||
)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs pr-8"
|
||||
/>
|
||||
<button
|
||||
@@ -107,6 +161,8 @@ export function QuickConnectPanel() {
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||
)}
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,11 +181,17 @@ export function QuickConnectPanel() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors">
|
||||
<button
|
||||
onClick={() => connect("terminal")}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToTerminal")}
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors">
|
||||
<button
|
||||
onClick={() => connect("files")}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<FolderSearch className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToFiles")}
|
||||
</button>
|
||||
|
||||
+819
-107
File diff suppressed because it is too large
Load Diff
+756
-169
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,15 @@ const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"3-way-horizontal": (
|
||||
<div className="flex flex-col gap-0.5 size-full">
|
||||
<div className="flex gap-0.5 flex-1">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
),
|
||||
"4-way": (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
|
||||
<div className="border-2 border-current" />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { Terminal } from "lucide-react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import { getCookie, setCookie } from "@/main-axios";
|
||||
|
||||
export function SshToolsPanel({
|
||||
terminalTabs,
|
||||
@@ -14,7 +15,9 @@ export function SshToolsPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [keyRecording, setKeyRecording] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(
|
||||
() => getCookie("rightClickCopyPaste") !== "false",
|
||||
);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -23,6 +26,11 @@ export function SshToolsPanel({
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyRecording) inputRef.current?.focus();
|
||||
}, [keyRecording]);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
@@ -40,6 +48,89 @@ export function SshToolsPanel({
|
||||
setSelectedTabIds(new Set());
|
||||
}
|
||||
|
||||
function broadcast(data: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
(tab?.terminalRef?.current as any)?.sendInput?.(data);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const ctrl = e.ctrlKey;
|
||||
const { key } = e;
|
||||
|
||||
if (ctrl) {
|
||||
const ctrlMap: Record<string, string> = {
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
l: "\x0C",
|
||||
u: "\x15",
|
||||
k: "\x0B",
|
||||
a: "\x01",
|
||||
e: "\x05",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
r: "\x12",
|
||||
};
|
||||
const seq = ctrlMap[key.toLowerCase()];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const specialMap: Record<string, string> = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7F",
|
||||
Delete: "\x1B[3~",
|
||||
Tab: "\t",
|
||||
Escape: "\x1B",
|
||||
ArrowUp: "\x1B[A",
|
||||
ArrowDown: "\x1B[B",
|
||||
ArrowRight: "\x1B[C",
|
||||
ArrowLeft: "\x1B[D",
|
||||
Home: "\x1B[H",
|
||||
End: "\x1B[F",
|
||||
PageUp: "\x1B[5~",
|
||||
PageDown: "\x1B[6~",
|
||||
Insert: "\x1B[2~",
|
||||
F1: "\x1BOP",
|
||||
F2: "\x1BOQ",
|
||||
F3: "\x1BOR",
|
||||
F4: "\x1BOS",
|
||||
F5: "\x1B[15~",
|
||||
F6: "\x1B[17~",
|
||||
F7: "\x1B[18~",
|
||||
F8: "\x1B[19~",
|
||||
F9: "\x1B[20~",
|
||||
F10: "\x1B[21~",
|
||||
F11: "\x1B[23~",
|
||||
F12: "\x1B[24~",
|
||||
};
|
||||
|
||||
const seq = specialMap[key];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
|
||||
broadcast(key);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
const next = !keyRecording;
|
||||
if (!next) {
|
||||
// clear the phantom text when stopping
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
setKeyRecording(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -114,14 +205,24 @@ export function SshToolsPanel({
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
|
||||
onClick={() => setKeyRecording((o) => !o)}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
{keyRecording
|
||||
? `Stop Recording (${selectedTabIds.size})`
|
||||
? `${t("newUi.sidebar.sshTools.stopRecording")} (${selectedTabIds.size})`
|
||||
: selectedTabIds.size === 0
|
||||
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
|
||||
: `Start Recording (${selectedTabIds.size})`}
|
||||
: `${t("newUi.sidebar.sshTools.startRecording")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
|
||||
{keyRecording && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
readOnly
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")}
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -135,7 +236,11 @@ export function SshToolsPanel({
|
||||
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRightClickPaste((o) => !o)}
|
||||
onClick={() => {
|
||||
const next = !rightClickPaste;
|
||||
setRightClickPaste(next);
|
||||
setCookie("rightClickCopyPaste", next ? "true" : "false");
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
rightClickPaste
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
|
||||
+628
-141
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user