mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: initial ui redesign from demo
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Clock,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
Play,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { SplitMode, ToolsTab } from "@/types/ui-types";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
| "quick-connect"
|
||||
| ToolsTab
|
||||
| "user-profile"
|
||||
| "admin-settings";
|
||||
|
||||
type RailItem =
|
||||
| {
|
||||
kind?: undefined;
|
||||
view: RailView;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(splitMode: SplitMode): RailItem[] {
|
||||
return [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: "Hosts" },
|
||||
{ kind: "separator" },
|
||||
{ view: "quick-connect", icon: <Zap size={16} />, title: "Quick Connect" },
|
||||
{ kind: "separator" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: "SSH Tools" },
|
||||
{ kind: "separator" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: "Snippets" },
|
||||
{ kind: "separator" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: "History" },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "split-screen",
|
||||
icon: <LayoutPanelLeft size={16} />,
|
||||
title: "Split Screen",
|
||||
dot: splitMode !== "none",
|
||||
},
|
||||
{ kind: "separator" },
|
||||
];
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center gap-2.5 h-7 rounded shrink-0 transition-colors";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 6px" };
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
username,
|
||||
profileDropdownOpen,
|
||||
onProfileDropdownChange,
|
||||
onRailClick,
|
||||
onLogout,
|
||||
}: {
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
username: string;
|
||||
profileDropdownOpen: boolean;
|
||||
onProfileDropdownChange: (open: boolean) => void;
|
||||
onRailClick: (view: RailView) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const railExpanded = hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200"
|
||||
style={{ width: railExpanded ? 160 : 40 }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
{railButtons.map((item, i) =>
|
||||
item.kind === "separator" ? (
|
||||
<div
|
||||
key={`sep-${i}`}
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(item.view)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "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>
|
||||
{item.dot && (
|
||||
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-accent-brand" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</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) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(item.view)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<DropdownMenu
|
||||
open={profileDropdownOpen}
|
||||
onOpenChange={onProfileDropdownChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
style={{ width: 24, height: 24, fontSize: 11 }}
|
||||
>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || "User"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
Administrator
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="!w-auto min-w-max [clip-path:inset(-4px_-4px_-4px_0px)]"
|
||||
>
|
||||
<DropdownMenuItem variant="destructive" onClick={onLogout}>
|
||||
<KeyRound size={14} />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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 type { Tab } from "@/types/ui-types";
|
||||
|
||||
export function HistoryPanel({
|
||||
activeTabId,
|
||||
terminalTabs,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
terminalTabs: Tab[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commands, setCommands] = useState<string[]>([]);
|
||||
|
||||
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) {
|
||||
setCommands([]);
|
||||
return;
|
||||
}
|
||||
getCommandHistory(hostId)
|
||||
.then(setCommands)
|
||||
.catch(() => setCommands([]));
|
||||
}, [hostId]);
|
||||
|
||||
if (!activeIsTerminal) {
|
||||
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.noTerminalSelected")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("newUi.sidebar.history.noTerminalSelectedHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? commands.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
||||
: commands;
|
||||
|
||||
async function handleDelete(cmd: string) {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await deleteCommandFromHistory(hostId, cmd);
|
||||
setCommands((prev) => prev.filter((c) => c !== cmd));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/30 border border-border/60">
|
||||
<Terminal className="size-3 shrink-0 text-accent-brand" />
|
||||
<span className="text-xs font-medium truncate text-foreground">
|
||||
{activeTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.history.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filtered.length} command{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommands([])}
|
||||
className="text-xs text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.history.clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filtered.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 text-center py-8">
|
||||
{t("newUi.sidebar.history.noHistoryEntries")}
|
||||
</span>
|
||||
)}
|
||||
{filtered.map((cmd, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex flex-col gap-1 px-2.5 py-2 border border-border bg-background hover:border-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-mono text-foreground break-all leading-relaxed">
|
||||
{cmd}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigator.clipboard.writeText(cmd)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(cmd)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { Search, Settings, X } from "lucide-react";
|
||||
import { SidebarTree } from "@/sidebar/SidebarTree";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
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,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<HostManager
|
||||
onCollapse={onCollapse}
|
||||
pendingEditId={pendingEditId}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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" />
|
||||
<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"
|
||||
/>
|
||||
{hostSearch && (
|
||||
<button
|
||||
onClick={() => setHostSearch("")}
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
<SidebarTree
|
||||
children={hostTree?.children ?? []}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
query={hostSearch.trim().toLowerCase()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
|
||||
export function QuickConnectPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
const [username, setUsername] = useState("");
|
||||
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
|
||||
"password",
|
||||
);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.hostLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.portLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.usernameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.authLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["password", "key", "credential"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setAuthType(type)}
|
||||
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
|
||||
authType === type
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{authType === "password" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.passwordLabel")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.passwordPlaceholder",
|
||||
)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-7 text-xs pr-8"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPassword((o) => !o)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{authType === "key" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
{authType === "credential" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.credentialLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.credentialPlaceholder",
|
||||
)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</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">
|
||||
<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">
|
||||
<FolderSearch className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToFiles")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Pencil,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
|
||||
export function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
const SSH_ACTIONS: { type: TabType; icon: typeof Terminal; label: string }[] = [
|
||||
{ type: "terminal", icon: Terminal, label: "Terminal" },
|
||||
{ type: "stats", icon: Server, label: "Stats" },
|
||||
{ type: "files", icon: FolderSearch, label: "Files" },
|
||||
{ type: "docker", icon: Box, label: "Docker" },
|
||||
{ type: "tunnel", icon: Network, label: "Tunnel" },
|
||||
];
|
||||
|
||||
function hostMatchesQuery(host: Host, query: string) {
|
||||
return (
|
||||
host.name.toLowerCase().includes(query) ||
|
||||
host.ip.toLowerCase().includes(query) ||
|
||||
host.username.toLowerCase().includes(query) ||
|
||||
host.tags?.some((t) => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
function folderHasMatch(folder: HostFolder, query: string): boolean {
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
if (folderHasMatch(child, query)) return true;
|
||||
} else {
|
||||
if (hostMatchesQuery(child, query)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walks the visible tree in render order and pushes every visible row
|
||||
// (folder header + hosts) into `out`. This gives us a flat ordered list
|
||||
// to assign a single global stripe counter across folders and hosts.
|
||||
function collectVisibleRows(
|
||||
children: (Host | HostFolder)[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
out: (Host | HostFolder)[] = [],
|
||||
): (Host | HostFolder)[] {
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
const visible = query ? folderHasMatch(child, query) : true;
|
||||
if (!visible) continue;
|
||||
out.push(child); // folder header row counts
|
||||
const childOpen = query ? true : openSet.has(child.name);
|
||||
if (childOpen) collectVisibleRows(child.children, query, openSet, out);
|
||||
} else {
|
||||
if (!query || hostMatchesQuery(child, query)) out.push(child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function folderHostCount(folder: HostFolder): {
|
||||
total: number;
|
||||
online: number;
|
||||
} {
|
||||
let total = 0,
|
||||
online = 0;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
const c = folderHostCount(child);
|
||||
total += c.total;
|
||||
online += c.online;
|
||||
} else {
|
||||
total++;
|
||||
if (child.online) online++;
|
||||
}
|
||||
}
|
||||
return { total, online };
|
||||
}
|
||||
|
||||
export function HostItem({
|
||||
host,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
query = "",
|
||||
stripeIndex = 0,
|
||||
}: {
|
||||
host: Host;
|
||||
onOpenTab: (type: TabType) => void;
|
||||
onEditHost?: () => void;
|
||||
query?: string;
|
||||
stripeIndex?: number;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
if (query && !hostMatchesQuery(host, query)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onOpenTab("terminal")}
|
||||
>
|
||||
{/* Status stripe */}
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${host.online ? "bg-accent-brand" : "bg-transparent"}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
|
||||
{/* Name + dot */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/25"}`}
|
||||
/>
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{host.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Address — only visible on hover */}
|
||||
<span
|
||||
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 ${hovered ? "opacity-100" : "opacity-0 h-0 overflow-hidden"}`}
|
||||
>
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
{/* Action tray — slides open on hover */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-150 ease-out"
|
||||
style={{
|
||||
maxHeight: hovered ? "200px" : "0px",
|
||||
opacity: hovered ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{host.online &&
|
||||
(host.cpu !== undefined || host.ram !== undefined) && (
|
||||
<div className="flex items-center gap-3 pl-3">
|
||||
{host.cpu !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="size-2.5 shrink-0 text-muted-foreground/30" />
|
||||
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
|
||||
style={{ width: `${host.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground/40">
|
||||
{host.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{host.ram !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/30" />
|
||||
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
|
||||
style={{ width: `${host.ram}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground/40">
|
||||
{host.ram}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1">
|
||||
{host.enableSsh &&
|
||||
SSH_ACTIONS.map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
RDP
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
VNC
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
Telnet
|
||||
</button>
|
||||
)}
|
||||
{onEditHost && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
<button
|
||||
title="Edit Host"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
folder,
|
||||
depth = 0,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
query = "",
|
||||
stripeMap,
|
||||
openFolders,
|
||||
onToggleFolder,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
depth?: number;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
query?: string;
|
||||
stripeMap: Map<Host | HostFolder, number>;
|
||||
openFolders: Set<string>;
|
||||
onToggleFolder: (name: string) => void;
|
||||
}) {
|
||||
const { total, online } = folderHostCount(folder);
|
||||
|
||||
if (query && !folderHasMatch(folder, query)) return null;
|
||||
|
||||
const isOpen = query ? true : openFolders.has(folder.name);
|
||||
const stripeIndex = stripeMap.get(folder) ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => !query && onToggleFolder(folder.name)}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<FolderOpen
|
||||
className={`size-3.5 shrink-0 ${isOpen ? "text-accent-brand" : "text-muted-foreground/60"}`}
|
||||
/>
|
||||
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums shrink-0 ml-1">
|
||||
{online > 0 && (
|
||||
<span className="text-accent-brand font-semibold">{online}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">/{total}</span>
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-l border-border/40 ml-[30px]">
|
||||
{folder.children.map((child, i) =>
|
||||
isFolder(child) ? (
|
||||
<FolderItem
|
||||
key={i}
|
||||
folder={child}
|
||||
depth={depth + 1}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
query={query}
|
||||
stripeMap={stripeMap}
|
||||
openFolders={openFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
key={i}
|
||||
host={child}
|
||||
onOpenTab={(t) => onOpenTab(child, t)}
|
||||
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
|
||||
query={query}
|
||||
stripeIndex={stripeMap.get(child) ?? 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Top-level tree renderer — owns open state and global stripe index.
|
||||
export function SidebarTree({
|
||||
children,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
query = "",
|
||||
}: {
|
||||
children: (Host | HostFolder)[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
query?: string;
|
||||
}) {
|
||||
const [openFolders, setOpenFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
function toggleFolder(name: string) {
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = collectVisibleRows(children, query, openFolders);
|
||||
const stripeMap = new Map<Host | HostFolder, number>(
|
||||
visibleRows.map((r, i) => [r, i]),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children.map((child, i) =>
|
||||
isFolder(child) ? (
|
||||
<FolderItem
|
||||
key={i}
|
||||
folder={child}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
query={query}
|
||||
stripeMap={stripeMap}
|
||||
openFolders={openFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
key={i}
|
||||
host={child}
|
||||
onOpenTab={(t) => onOpenTab(child, t)}
|
||||
onEditHost={() => onEditHost(child)}
|
||||
query={query}
|
||||
stripeIndex={stripeMap.get(child) ?? 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getSnippets,
|
||||
createSnippet as apiCreateSnippet,
|
||||
deleteSnippet as apiDeleteSnippet,
|
||||
} from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
Folder,
|
||||
Globe,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { FOLDER_COLORS } from "@/lib/theme";
|
||||
import { FOLDER_ICONS } from "@/types/ui-types";
|
||||
import type {
|
||||
Snippet,
|
||||
SnippetFolder,
|
||||
FolderIconId,
|
||||
Tab,
|
||||
} from "@/types/ui-types";
|
||||
|
||||
function FolderIconEl({
|
||||
icon,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
icon: FolderIconId;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const props = { className, style };
|
||||
switch (icon) {
|
||||
case "folder":
|
||||
return <Folder {...props} />;
|
||||
case "server":
|
||||
return <Server {...props} />;
|
||||
case "cloud":
|
||||
return (
|
||||
<div {...props}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case "database":
|
||||
return <Database {...props} />;
|
||||
case "box":
|
||||
return <Box {...props} />;
|
||||
case "network":
|
||||
return <Network {...props} />;
|
||||
case "copy":
|
||||
return <Copy {...props} />;
|
||||
case "settings":
|
||||
return <Settings {...props} />;
|
||||
case "cpu":
|
||||
return <Cpu {...props} />;
|
||||
case "globe":
|
||||
return <Globe {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function CreateSnippetDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
folders,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
folders: SnippetFolder[];
|
||||
onCreate: (s: Omit<Snippet, "id">) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [folderId, setFolderId] = useState<number | null>(null);
|
||||
const [command, setCommand] = useState("");
|
||||
|
||||
function handleCreate() {
|
||||
if (!name.trim() || !command.trim()) return;
|
||||
onCreate({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
command: command.trim(),
|
||||
folderId,
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFolderId(null);
|
||||
setCommand("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.createSnippetTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.createSnippetDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.nameLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.snippets.namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.descriptionLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.snippets.descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Folder className="size-3.5" />
|
||||
{t("newUi.sidebar.snippets.folderLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={folderId ?? ""}
|
||||
onChange={(e) =>
|
||||
setFolderId(
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
|
||||
{folders
|
||||
.filter((f) => f.name !== "Uncategorized")
|
||||
.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.commandLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={t("newUi.sidebar.snippets.commandPlaceholder")}
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
className="w-full h-36 px-3 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>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.createSnippetButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFolderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onCreate: (f: Omit<SnippetFolder, "id" | "open">) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(FOLDER_COLORS[0]);
|
||||
const [icon, setIcon] = useState<FolderIconId>("folder");
|
||||
|
||||
function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
onCreate({ name: name.trim(), color, icon });
|
||||
setName("");
|
||||
setColor(FOLDER_COLORS[0]);
|
||||
setIcon("folder");
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.createFolderTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.createFolderDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.folderNameLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.snippets.folderNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.folderColorLabel")}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{FOLDER_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-10 transition-all ${color === c ? "ring-2 ring-offset-2 ring-offset-background ring-white/50" : "opacity-75 hover:opacity-100"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.folderIconLabel")}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{FOLDER_ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
onClick={() => setIcon(ic)}
|
||||
className={`flex items-center justify-center h-11 border transition-colors ${
|
||||
icon === ic
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FolderIconEl icon={ic} className="size-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.previewLabel")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
|
||||
<FolderIconEl
|
||||
icon={icon}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold">
|
||||
{name || t("newUi.sidebar.snippets.folderNameFallback")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.createFolderButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
}: {
|
||||
terminalTabs: Tab[];
|
||||
activeTabId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [snippetSearch, setSnippetSearch] = useState("");
|
||||
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapped: Snippet[] = arr.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
command: s.command,
|
||||
folderId: s.folderId ?? null,
|
||||
}));
|
||||
setSnippets(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [createSnippetOpen, setCreateSnippetOpen] = useState(false);
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((t) => t.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateSnippet(s: Omit<Snippet, "id">) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippet(s as any)) as any;
|
||||
setSnippets((prev) => [
|
||||
...prev,
|
||||
{ ...s, id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1 },
|
||||
]);
|
||||
toast.success("Snippet created successfully");
|
||||
} catch {
|
||||
toast.error("Failed to create snippet");
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
|
||||
const id = Math.max(0, ...folders.map((x) => x.id)) + 1;
|
||||
setFolders((prev) => [...prev, { ...f, id, open: true }]);
|
||||
toast.success("Folder created successfully");
|
||||
}
|
||||
|
||||
function toggleFolder(id: number) {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, open: !f.open } : f)),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteSnippet(id: number) {
|
||||
try {
|
||||
await apiDeleteSnippet(id);
|
||||
setSnippets((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch {
|
||||
toast.error("Failed to delete snippet");
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = snippetSearch
|
||||
? snippets.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(snippetSearch.toLowerCase()) ||
|
||||
s.command.toLowerCase().includes(snippetSearch.toLowerCase()),
|
||||
)
|
||||
: snippets;
|
||||
|
||||
const namedFolders = folders.filter((f) => f.name !== "Uncategorized");
|
||||
const uncategorized = folders.find((f) => f.name === "Uncategorized");
|
||||
const allFolders = [
|
||||
...namedFolders,
|
||||
...(uncategorized ? [uncategorized] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.targetTerminals")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</span>
|
||||
{terminalTabs.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedTabIds(new Set(terminalTabs.map((t) => t.id)))
|
||||
}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.selectAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTabIds(new Set())}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.selectNone")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{terminalTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
|
||||
<Terminal className="size-3 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{t("newUi.sidebar.snippets.noTerminalTabsOpen")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{terminalTabs.map((tab) => {
|
||||
const selected = selectedTabIds.has(tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => toggleTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Terminal className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.snippets.searchPlaceholder")}
|
||||
value={snippetSearch}
|
||||
onChange={(e) => setSnippetSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden"
|
||||
onClick={() => setCreateSnippetOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.newSnippet")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden"
|
||||
onClick={() => setCreateFolderOpen(true)}
|
||||
>
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.newFolder")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{allFolders.map((folder) => {
|
||||
const folderSnippets = filtered.filter((s) =>
|
||||
folder.name === "Uncategorized"
|
||||
? s.folderId === null || s.folderId === folder.id
|
||||
: s.folderId === folder.id,
|
||||
);
|
||||
if (folderSnippets.length === 0 && snippetSearch) return null;
|
||||
return (
|
||||
<div key={folder.id} className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex items-center gap-1.5 w-full text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${folder.open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<FolderIconEl
|
||||
icon={folder.icon}
|
||||
className="size-3.5 shrink-0"
|
||||
style={{ color: folder.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-semibold flex-1 truncate"
|
||||
style={{
|
||||
color:
|
||||
folder.name === "Uncategorized"
|
||||
? undefined
|
||||
: folder.color,
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{folderSnippets.length}
|
||||
</span>
|
||||
</button>
|
||||
{folder.open && (
|
||||
<div className="flex flex-col gap-2 ml-1">
|
||||
{folderSnippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="border border-border bg-background p-2.5 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">
|
||||
{snippet.name}
|
||||
</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.command}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => deleteSnippet(snippet.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{folderSnippets.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 pl-1">
|
||||
{t("newUi.sidebar.snippets.noSnippetsInFolder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateSnippetDialog
|
||||
open={createSnippetOpen}
|
||||
onOpenChange={setCreateSnippetOpen}
|
||||
folders={folders}
|
||||
onCreate={handleCreateSnippet}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={createFolderOpen}
|
||||
onOpenChange={setCreateFolderOpen}
|
||||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { LayoutPanelLeft, X } from "lucide-react";
|
||||
import { PANE_COUNTS, SPLIT_MODES } from "@/lib/theme";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, SplitMode } from "@/types/ui-types";
|
||||
|
||||
const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
|
||||
none: <div className="size-full border-2 border-current" />,
|
||||
"2-way": (
|
||||
<div className="flex gap-0.5 size-full">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
),
|
||||
"3-way": (
|
||||
<div className="flex gap-0.5 size-full">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex flex-col flex-1 gap-0.5">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"4-way": (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
</div>
|
||||
),
|
||||
"5-way": (
|
||||
<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 className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-1">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex-[2] border-2 border-current" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"6-way": (
|
||||
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 size-full">
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
<div className="border-2 border-current" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export function SplitScreenPanel({
|
||||
tabs,
|
||||
splitMode,
|
||||
setSplitMode,
|
||||
paneTabIds,
|
||||
setPaneTabIds,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
splitMode: SplitMode;
|
||||
setSplitMode: (m: SplitMode) => void;
|
||||
paneTabIds: (string | null)[];
|
||||
setPaneTabIds: (ids: (string | null)[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const paneCount = PANE_COUNTS[splitMode];
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const [dragOverPane, setDragOverPane] = useState<number | null>(null);
|
||||
|
||||
function handleDrop(paneIndex: number) {
|
||||
if (draggingTabId === null) return;
|
||||
const next = [...paneTabIds];
|
||||
next[paneIndex] = draggingTabId;
|
||||
setPaneTabIds(next);
|
||||
setDraggingTabId(null);
|
||||
setDragOverPane(null);
|
||||
}
|
||||
|
||||
function clearPane(paneIndex: number) {
|
||||
const next = [...paneTabIds];
|
||||
next[paneIndex] = null;
|
||||
setPaneTabIds(next);
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
setSplitMode("none");
|
||||
setPaneTabIds(Array(6).fill(null));
|
||||
}
|
||||
|
||||
const activeCount = paneTabIds
|
||||
.slice(0, Math.max(paneCount, 0))
|
||||
.filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.splitScreen.layoutTitle")}
|
||||
</span>
|
||||
{splitMode !== "none" && (
|
||||
<span className="text-xs border border-accent-brand/40 text-accent-brand px-1.5 py-0.5 leading-tight">
|
||||
{splitMode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SPLIT_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setSplitMode(mode.id)}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 border transition-colors ${
|
||||
splitMode === mode.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-7 ${splitMode === mode.id ? "text-accent-brand" : "text-muted-foreground/40"}`}
|
||||
>
|
||||
{LAYOUT_PREVIEWS[mode.id]}
|
||||
</div>
|
||||
<span className="text-xs font-semibold">{mode.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{splitMode === "none" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center border border-dashed border-border">
|
||||
<LayoutPanelLeft className="size-8 text-muted-foreground/30" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("newUi.sidebar.splitScreen.selectLayoutAbove")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("newUi.sidebar.splitScreen.selectLayoutHint")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.splitScreen.panesTitle")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activeCount}/{paneCount} assigned
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{Array.from({ length: paneCount }).map((_, i) => {
|
||||
const assignedId = paneTabIds[i];
|
||||
const assignedTab = assignedId
|
||||
? tabs.find((t) => t.id === assignedId)
|
||||
: null;
|
||||
const isOver = dragOverPane === i;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverPane(i);
|
||||
}}
|
||||
onDragLeave={() => setDragOverPane(null)}
|
||||
onDrop={() => handleDrop(i)}
|
||||
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
|
||||
isOver
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: assignedTab
|
||||
? "border-border bg-muted/20"
|
||||
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
{assignedTab ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-1 w-full justify-center">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(assignedTab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-semibold truncate max-w-[70px]">
|
||||
{assignedTab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: assignedTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => clearPane(i)}
|
||||
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{isOver
|
||||
? t("newUi.sidebar.splitScreen.dropHere")
|
||||
: t("newUi.sidebar.splitScreen.emptyPane")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.splitScreen.openTabsTitle")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("newUi.sidebar.splitScreen.dragTabsHint")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 mt-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggingTabId(tab.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTabId(null);
|
||||
setDragOverPane(null);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-2.5 py-2 border cursor-grab active:cursor-grabbing select-none transition-colors ${
|
||||
draggingTabId === tab.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-medium flex-1 truncate">
|
||||
{tab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: tab.label}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground"
|
||||
onClick={resetAll}
|
||||
>
|
||||
<X className="size-3" />
|
||||
{t("newUi.sidebar.splitScreen.clearSplitScreen")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } 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";
|
||||
|
||||
export function SshToolsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
}: {
|
||||
terminalTabs: Tab[];
|
||||
activeTabId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [keyRecording, setKeyRecording] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((t) => t.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
setSelectedTabIds(new Set(terminalTabs.map((t) => t.id)));
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
setSelectedTabIds(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("newUi.sidebar.sshTools.keyRecordingTitle")}
|
||||
</span>
|
||||
|
||||
{/* Terminal selector */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.sshTools.recordToTerminals")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.sshTools.selectAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.sshTools.selectNone")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{terminalTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
|
||||
<Terminal className="size-3 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{t("newUi.sidebar.sshTools.noTerminalTabsOpen")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{terminalTabs.map((tab) => {
|
||||
const selected = selectedTabIds.has(tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => toggleTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Terminal className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
{keyRecording
|
||||
? `Stop Recording (${selectedTabIds.size})`
|
||||
: selectedTabIds.size === 0
|
||||
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
|
||||
: `Start Recording (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("newUi.sidebar.sshTools.settingsTitle")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRightClickPaste((o) => !o)}
|
||||
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"
|
||||
: "bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${rightClickPaste ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user