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:
@@ -0,0 +1,395 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandSeparator,
|
||||
} from "@/components/command";
|
||||
import {
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Box,
|
||||
Globe,
|
||||
Plus,
|
||||
MessagesSquare,
|
||||
LifeBuoy,
|
||||
DollarSign,
|
||||
Search,
|
||||
Activity,
|
||||
Network,
|
||||
MoreHorizontal,
|
||||
Edit3,
|
||||
User,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
|
||||
type Host = {
|
||||
name: string;
|
||||
user: string;
|
||||
address: string;
|
||||
online: boolean;
|
||||
cpu: number;
|
||||
ram: number;
|
||||
lastAccess: string;
|
||||
tags?: string[];
|
||||
enableTerminal?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
enableDocker?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
hosts: Host[];
|
||||
onOpenTab: (type: any, label?: string, pendingEvent?: string) => void;
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||
Terminal: <Terminal className="size-3" />,
|
||||
Files: <FolderOpen className="size-3" />,
|
||||
Docker: <Box className="size-3" />,
|
||||
Stats: <Activity className="size-3" />,
|
||||
Tunnels: <Network className="size-3" />,
|
||||
};
|
||||
|
||||
const ACTION_TAB_TYPE: Record<string, string> = {
|
||||
Terminal: "terminal",
|
||||
Files: "files",
|
||||
Docker: "docker",
|
||||
Stats: "stats",
|
||||
Tunnels: "tunnel",
|
||||
};
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
hosts,
|
||||
onOpenTab,
|
||||
}: CommandPaletteProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
setSearch("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const filteredHosts = hosts.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.ip.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] bg-background/40 backdrop-blur-sm transition-all duration-200 animate-in fade-in",
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-2xl mx-4 overflow-hidden rounded-none border border-border bg-card shadow-2xl animate-in zoom-in-95 duration-200",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command className="rounded-none">
|
||||
<div className="flex items-center border-b border-border px-4 py-1">
|
||||
<Search className="size-4 text-muted-foreground mr-3" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search hosts, commands, or settings..."
|
||||
className="flex-1 h-12 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Kbd className="bg-muted/50 border-none h-6 px-2 text-[11px] rounded-none">
|
||||
ESC
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandList className="max-h-[60vh] thin-scrollbar">
|
||||
<CommandGroup heading="Quick Actions" className="px-2">
|
||||
<CommandItem
|
||||
onSelect={() => handleAction(() => onOpenTab("host-manager"))}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
<LayoutDashboard className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Host Manager</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Manage, add, or edit hosts
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
handleAction(() =>
|
||||
onOpenTab(
|
||||
"host-manager",
|
||||
undefined,
|
||||
"host-manager:add-host",
|
||||
),
|
||||
)
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
<Plus className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Add New Host</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Register a new host
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
onSelect={() => handleAction(() => onOpenTab("admin-settings"))}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Admin Settings</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Configure system preferences and users
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
onSelect={() => handleAction(() => onOpenTab("user-profile"))}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
<User className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">User Profile</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Manage your account and preferences
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
handleAction(() =>
|
||||
onOpenTab(
|
||||
"host-manager",
|
||||
undefined,
|
||||
"host-manager:add-credential",
|
||||
),
|
||||
)
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
<KeyRound className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Add Credential</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Store SSH keys or passwords
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator className="my-2" />
|
||||
|
||||
<CommandGroup heading="Servers & Hosts" className="px-2">
|
||||
{filteredHosts.length > 0 ? (
|
||||
filteredHosts.map((host, i) => {
|
||||
const actions = [
|
||||
host.enableTerminal !== false && "Terminal",
|
||||
host.enableFileManager !== false && "Files",
|
||||
host.enableDocker && "Docker",
|
||||
host.enableTunnel && "Tunnels",
|
||||
"Stats",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={i}
|
||||
onSelect={() =>
|
||||
handleAction(() => onOpenTab("terminal", host.name))
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
|
||||
<Server
|
||||
className={cn(
|
||||
"size-4",
|
||||
host.online
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.online && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={action}
|
||||
className="size-7 rounded-none hover:bg-accent-brand/20 hover:text-accent-brand"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab(
|
||||
ACTION_TAB_TYPE[action] as any,
|
||||
host.name,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{ACTION_ICONS[action]}
|
||||
</Button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-none hover:bg-muted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="rounded-none border-border bg-card w-40"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-none text-xs font-semibold hover:bg-accent-brand/10 hover:text-accent-brand cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() => onOpenTab("host-manager"));
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 mr-2" /> Edit Host
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No hosts found matching "{search}"
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator className="my-2" />
|
||||
|
||||
<CommandGroup heading="Links" className="px-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<CommandItem
|
||||
onSelect={() => window.open("https://github.com", "_blank")}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">GitHub</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => window.open("https://discord.com", "_blank")}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<MessagesSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Discord</span>
|
||||
</CommandItem>
|
||||
<CommandItem className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer">
|
||||
<LifeBuoy className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Support</span>
|
||||
</CommandItem>
|
||||
<CommandItem className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer">
|
||||
<DollarSign className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Donate</span>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 bg-muted/30 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd className="h-5 px-1 bg-background rounded-none">↑↓</Kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd className="h-5 px-1 bg-background rounded-none">ENTER</Kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Toggle with</span>
|
||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
||||
<span>+</span>
|
||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Clock,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { RailView } from "@/sidebar/AppRail";
|
||||
import type { SplitMode } from "@/types/ui-types";
|
||||
|
||||
const PRIMARY_ITEMS: {
|
||||
view: RailView;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ view: "hosts", icon: <Server className="size-5" />, title: "Hosts" },
|
||||
{ view: "quick-connect", icon: <Zap className="size-5" />, title: "Connect" },
|
||||
{
|
||||
view: "ssh-tools",
|
||||
icon: <Hammer className="size-5" />,
|
||||
title: "SSH Tools",
|
||||
},
|
||||
{ view: "snippets", icon: <Play className="size-5" />, title: "Snippets" },
|
||||
];
|
||||
|
||||
const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [
|
||||
{ view: "history", icon: <Clock className="size-4" />, title: "History" },
|
||||
{
|
||||
view: "split-screen",
|
||||
icon: <LayoutPanelLeft className="size-4" />,
|
||||
title: "Split Screen",
|
||||
},
|
||||
{ view: "user-profile", icon: <User className="size-4" />, title: "Profile" },
|
||||
{
|
||||
view: "admin-settings",
|
||||
icon: <Settings className="size-4" />,
|
||||
title: "Admin",
|
||||
},
|
||||
];
|
||||
|
||||
export function MobileBottomBar({
|
||||
railView,
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
onRailClick,
|
||||
}: {
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
onRailClick: (view: RailView) => void;
|
||||
}) {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreActive = MORE_ITEMS.some((i) => i.view === railView) && sidebarOpen;
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex items-stretch shrink-0 bg-sidebar border-t border-border safe-bottom">
|
||||
{PRIMARY_ITEMS.map((item) => {
|
||||
const active = sidebarOpen && railView === item.view;
|
||||
const hasDot = item.view === "split-screen" && splitMode !== "none";
|
||||
return (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(item.view)}
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${active ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
{hasDot && (
|
||||
<span className="absolute top-1.5 right-[calc(50%-10px)] size-1.5 rounded-full bg-accent-brand" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
|
||||
${moreActive ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<span>More</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="mb-1 min-w-[180px]"
|
||||
>
|
||||
{MORE_ITEMS.map((item, i) => {
|
||||
const active = sidebarOpen && railView === item.view;
|
||||
if (item.view === "user-profile" && i > 0) {
|
||||
return (
|
||||
<React.Fragment key={item.view}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onRailClick(item.view);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
className={active ? "text-accent-brand" : ""}
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.view}
|
||||
onClick={() => {
|
||||
onRailClick(item.view);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
className={active ? "text-accent-brand" : ""}
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => window.dispatchEvent(new Event("termix:logout"))}
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
|
||||
import { renderTabContent, tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RowColSizes = number[][];
|
||||
|
||||
// ─── useSplitSizes ────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultSizes(mode: SplitMode): {
|
||||
rowSizes: number[];
|
||||
rowColSizes: RowColSizes;
|
||||
} {
|
||||
switch (mode) {
|
||||
case "2-way":
|
||||
return { rowSizes: [100], rowColSizes: [[50, 50]] };
|
||||
case "3-way":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
|
||||
case "4-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[50, 50],
|
||||
[50, 50],
|
||||
],
|
||||
};
|
||||
case "5-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[33.3, 33.3, 33.4],
|
||||
[33.3, 66.7],
|
||||
],
|
||||
};
|
||||
case "6-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[33.3, 33.3, 33.4],
|
||||
[33.3, 33.3, 33.4],
|
||||
],
|
||||
};
|
||||
default:
|
||||
return { rowSizes: [100], rowColSizes: [[100]] };
|
||||
}
|
||||
}
|
||||
|
||||
function useSplitSizes(splitMode: SplitMode) {
|
||||
const init = defaultSizes(splitMode);
|
||||
const [rowSizes, setRowSizes] = useState(init.rowSizes);
|
||||
const [rowColSizes, setRowColSizes] = useState<RowColSizes>(init.rowColSizes);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const d = defaultSizes(splitMode);
|
||||
setRowSizes(d.rowSizes);
|
||||
setRowColSizes(d.rowColSizes);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [splitMode]);
|
||||
|
||||
function reset() {
|
||||
const d = defaultSizes(splitMode);
|
||||
setRowSizes(d.rowSizes);
|
||||
setRowColSizes(d.rowColSizes);
|
||||
}
|
||||
|
||||
function startDrag() {
|
||||
splitDragState.active = true;
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
splitDragState.active = false;
|
||||
setIsDragging(false);
|
||||
notifyDragEnd();
|
||||
}
|
||||
|
||||
function onRowDivider(e: React.MouseEvent, rowIdx: number) {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalH = container.getBoundingClientRect().height;
|
||||
const startY = e.clientY;
|
||||
const a = rowSizes[rowIdx];
|
||||
const b = rowSizes[rowIdx + 1];
|
||||
function onMove(ev: MouseEvent) {
|
||||
const delta = ((ev.clientY - startY) / totalH) * 100;
|
||||
const na = Math.max(10, Math.min(a + b - 10, a + delta));
|
||||
setRowSizes((prev) => {
|
||||
const n = [...prev];
|
||||
n[rowIdx] = na;
|
||||
n[rowIdx + 1] = a + b - na;
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function onRowDividerTouch(e: React.TouchEvent, rowIdx: number) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalH = container.getBoundingClientRect().height;
|
||||
const startY = e.touches[0].clientY;
|
||||
const a = rowSizes[rowIdx];
|
||||
const b = rowSizes[rowIdx + 1];
|
||||
function onMove(ev: TouchEvent) {
|
||||
const delta = ((ev.touches[0].clientY - startY) / totalH) * 100;
|
||||
const na = Math.max(10, Math.min(a + b - 10, a + delta));
|
||||
setRowSizes((prev) => {
|
||||
const n = [...prev];
|
||||
n[rowIdx] = na;
|
||||
n[rowIdx + 1] = a + b - na;
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("touchmove", onMove);
|
||||
window.removeEventListener("touchend", onUp);
|
||||
}
|
||||
window.addEventListener("touchmove", onMove, { passive: false });
|
||||
window.addEventListener("touchend", onUp);
|
||||
}
|
||||
|
||||
function onColDivider(e: React.MouseEvent, rowIdx: number, colIdx: number) {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalW = container.getBoundingClientRect().width;
|
||||
const startX = e.clientX;
|
||||
const cols = rowColSizes[rowIdx];
|
||||
const a = cols[colIdx];
|
||||
const b = cols[colIdx + 1];
|
||||
function onMove(ev: MouseEvent) {
|
||||
const delta = ((ev.clientX - startX) / totalW) * 100;
|
||||
const na = Math.max(10, Math.min(a + b - 10, a + delta));
|
||||
setRowColSizes((prev) => {
|
||||
const next = prev.map((r) => [...r]);
|
||||
next[rowIdx][colIdx] = na;
|
||||
next[rowIdx][colIdx + 1] = a + b - na;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function onColDividerTouch(
|
||||
e: React.TouchEvent,
|
||||
rowIdx: number,
|
||||
colIdx: number,
|
||||
) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalW = container.getBoundingClientRect().width;
|
||||
const startX = e.touches[0].clientX;
|
||||
const cols = rowColSizes[rowIdx];
|
||||
const a = cols[colIdx];
|
||||
const b = cols[colIdx + 1];
|
||||
function onMove(ev: TouchEvent) {
|
||||
const delta = ((ev.touches[0].clientX - startX) / totalW) * 100;
|
||||
const na = Math.max(10, Math.min(a + b - 10, a + delta));
|
||||
setRowColSizes((prev) => {
|
||||
const next = prev.map((r) => [...r]);
|
||||
next[rowIdx][colIdx] = na;
|
||||
next[rowIdx][colIdx + 1] = a + b - na;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("touchmove", onMove);
|
||||
window.removeEventListener("touchend", onUp);
|
||||
}
|
||||
window.addEventListener("touchmove", onMove, { passive: false });
|
||||
window.addEventListener("touchend", onUp);
|
||||
}
|
||||
|
||||
return {
|
||||
rowSizes,
|
||||
rowColSizes,
|
||||
isDragging,
|
||||
containerRef,
|
||||
reset,
|
||||
onRowDivider,
|
||||
onRowDividerTouch,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dividers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ColDivider({
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
}: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
className="relative w-3 shrink-0 cursor-col-resize z-10 flex items-center justify-center group"
|
||||
>
|
||||
<div className="w-px h-full bg-border group-hover:bg-accent-brand/60 transition-colors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDivider({
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
}: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
className="relative h-3 w-full shrink-0 cursor-row-resize z-10 flex flex-col items-center justify-center group"
|
||||
>
|
||||
<div className="h-px w-full bg-border group-hover:bg-accent-brand/60 transition-colors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pane ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PaneHeader({
|
||||
tab,
|
||||
paneIndex,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2.5 h-7 shrink-0 bg-sidebar border-b border-border text-xs font-medium text-muted-foreground select-none">
|
||||
{tab ? (
|
||||
<>
|
||||
<span className="opacity-60">{tabIcon(tab.type)}</span>
|
||||
<span className="truncate text-foreground">
|
||||
{tab.type === "dashboard" ? "Dashboard" : tab.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-40">Pane {paneIndex + 1} — empty</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPane() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/30 bg-background">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
</div>
|
||||
<span className="text-xs">No tab assigned</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({
|
||||
tab,
|
||||
paneIndex,
|
||||
isDragging,
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
isDragging: boolean;
|
||||
onOpenSingletonTab: (type: TabType) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col w-full h-full min-w-0 min-h-0 overflow-hidden">
|
||||
<PaneHeader tab={tab} paneIndex={paneIndex} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{tab ? (
|
||||
renderTabContent(tab, onOpenSingletonTab, onOpenTab)
|
||||
) : (
|
||||
<EmptyPane />
|
||||
)}
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-10" style={{ cursor: "inherit" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SplitView ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SplitView({
|
||||
tabs,
|
||||
paneTabIds,
|
||||
splitMode,
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
paneTabIds: (string | null)[];
|
||||
splitMode: SplitMode;
|
||||
onOpenSingletonTab: (type: TabType) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
const {
|
||||
rowSizes,
|
||||
rowColSizes,
|
||||
isDragging,
|
||||
containerRef,
|
||||
reset,
|
||||
onRowDivider,
|
||||
onRowDividerTouch,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
} = useSplitSizes(splitMode);
|
||||
|
||||
function pane(idx: number) {
|
||||
const tab =
|
||||
paneTabIds[idx] != null
|
||||
? (tabs.find((t) => t.id === paneTabIds[idx]) ?? null)
|
||||
: null;
|
||||
return (
|
||||
<Pane
|
||||
tab={tab}
|
||||
paneIndex={idx}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
rowIdx,
|
||||
paneIndices,
|
||||
}: {
|
||||
rowIdx: number;
|
||||
paneIndices: number[];
|
||||
}) {
|
||||
const cols = rowColSizes[rowIdx] ?? [];
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 w-full"
|
||||
style={{ height: `${rowSizes[rowIdx]}%` }}
|
||||
>
|
||||
{paneIndices.map((pIdx, ci) => (
|
||||
<>
|
||||
<div
|
||||
key={pIdx}
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${cols[ci]}%` }}
|
||||
>
|
||||
{pane(pIdx)}
|
||||
</div>
|
||||
{ci < paneIndices.length - 1 && (
|
||||
<ColDivider
|
||||
key={`cd-${rowIdx}-${ci}`}
|
||||
onMouseDown={(e) => onColDivider(e, rowIdx, ci)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col w-full h-full min-h-0 overflow-hidden relative"
|
||||
>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="absolute top-1 right-1 z-20 text-xs text-muted-foreground hover:text-foreground bg-background/80 border border-border px-1.5 py-0.5 leading-tight"
|
||||
title="Reset to equal split"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
{splitMode === "2-way" && <Row rowIdx={0} paneIndices={[0, 1]} />}
|
||||
|
||||
{splitMode === "3-way" && (
|
||||
<div className="flex w-full h-full min-h-0">
|
||||
<div
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${rowColSizes[0][0]}%` }}
|
||||
>
|
||||
{pane(0)}
|
||||
</div>
|
||||
<ColDivider
|
||||
onMouseDown={(e) => onColDivider(e, 0, 0)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
className="min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[0]}%` }}
|
||||
>
|
||||
{pane(1)}
|
||||
</div>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<div
|
||||
className="min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[1]}%` }}
|
||||
>
|
||||
{pane(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "4-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row rowIdx={0} paneIndices={[0, 1]} />
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row rowIdx={1} paneIndices={[2, 3]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "5-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row rowIdx={0} paneIndices={[0, 1, 2]} />
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row rowIdx={1} paneIndices={[3, 4]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "6-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row rowIdx={0} paneIndices={[0, 1, 2]} />
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row rowIdx={1} paneIndices={[3, 4, 5]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostPassword } from "@/main-axios.ts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Home,
|
||||
SeparatorVertical,
|
||||
X,
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
User as UserIcon,
|
||||
Monitor as MonitorIcon,
|
||||
Eye as EyeIcon,
|
||||
MessagesSquare as MessageSquareIcon,
|
||||
Network,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TabProps {
|
||||
tabType: string;
|
||||
title?: string;
|
||||
isActive?: boolean;
|
||||
isSplit?: boolean;
|
||||
onActivate?: () => void;
|
||||
onClose?: () => void;
|
||||
onSplit?: () => void;
|
||||
canSplit?: boolean;
|
||||
canClose?: boolean;
|
||||
disableActivate?: boolean;
|
||||
disableSplit?: boolean;
|
||||
disableClose?: boolean;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
isValidDropTarget?: boolean;
|
||||
isHoveredDropTarget?: boolean;
|
||||
hostConfig?: SSHHost;
|
||||
onOpenFileManager?: () => void;
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
tabType,
|
||||
title,
|
||||
isActive,
|
||||
isSplit = false,
|
||||
onActivate,
|
||||
onClose,
|
||||
onSplit,
|
||||
canSplit = false,
|
||||
canClose = false,
|
||||
disableActivate = false,
|
||||
disableSplit = false,
|
||||
disableClose = false,
|
||||
isDragging = false,
|
||||
isDragOver = false,
|
||||
isValidDropTarget = false,
|
||||
isHoveredDropTarget = false,
|
||||
hostConfig,
|
||||
onOpenFileManager,
|
||||
}: TabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopyPassword = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!hostConfig) return;
|
||||
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (!hasSshPw && !hasSudoPw) return;
|
||||
|
||||
const field = hasSshPw ? "password" : "sudoPassword";
|
||||
const passwordToCopy = await getHostPassword(hostConfig.id, field);
|
||||
|
||||
if (!passwordToCopy) {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(passwordToCopy);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = passwordToCopy;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hasPassword =
|
||||
hostConfig &&
|
||||
((hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password)) ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.sudoPassword);
|
||||
|
||||
const getPasswordButtonTitle = () => {
|
||||
if (!hostConfig) return "";
|
||||
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (hasSshPw) {
|
||||
return t("nav.copyPassword");
|
||||
} else if (hasSudoPw) {
|
||||
return t("nav.copySudoPassword");
|
||||
}
|
||||
return t("nav.noPasswordAvailable");
|
||||
};
|
||||
|
||||
const tabBaseClasses = cn(
|
||||
"relative flex items-center gap-1.5 px-3 w-full min-w-0",
|
||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||
"transition-all duration-150 h-[42px]",
|
||||
isDragOver &&
|
||||
"bg-background/40 text-muted-foreground border-border opacity-60",
|
||||
isDragging && "opacity-70",
|
||||
isHoveredDropTarget &&
|
||||
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
|
||||
!isHoveredDropTarget &&
|
||||
isValidDropTarget &&
|
||||
"border-blue-400/50 bg-background/90",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
!isValidDropTarget &&
|
||||
!isHoveredDropTarget &&
|
||||
isActive &&
|
||||
"bg-background text-foreground border-border z-10",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
!isValidDropTarget &&
|
||||
!isHoveredDropTarget &&
|
||||
!isActive &&
|
||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||
);
|
||||
|
||||
const splitTitle = (fullTitle: string): { base: string; suffix: string } => {
|
||||
const match = fullTitle.match(/^(.*?)(\s*\(\d+\))$/);
|
||||
if (match) {
|
||||
return { base: match[1], suffix: match[2] };
|
||||
}
|
||||
return { base: fullTitle, suffix: "" };
|
||||
};
|
||||
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-3 flex-shrink-0 cursor-pointer",
|
||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||
"transition-all duration-150 h-[42px]",
|
||||
isDragOver &&
|
||||
"bg-background/40 text-muted-foreground border-border opacity-60",
|
||||
isDragging && "opacity-70",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
isActive &&
|
||||
"bg-background text-foreground border-border z-10",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
!isActive &&
|
||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||
)}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tabType === "terminal" ||
|
||||
tabType === "server_stats" ||
|
||||
tabType === "file_manager" ||
|
||||
tabType === "rdp" ||
|
||||
tabType === "vnc" ||
|
||||
tabType === "telnet" ||
|
||||
tabType === "tunnel" ||
|
||||
tabType === "docker" ||
|
||||
tabType === "user_profile"
|
||||
) {
|
||||
const isServer = tabType === "server_stats";
|
||||
const isFileManager = tabType === "file_manager";
|
||||
const isTunnel = tabType === "tunnel";
|
||||
const isDocker = tabType === "docker";
|
||||
const isUserProfile = tabType === "user_profile";
|
||||
const displayTitle =
|
||||
title ||
|
||||
(isServer
|
||||
? t("nav.serverStats")
|
||||
: isFileManager
|
||||
? t("nav.fileManager")
|
||||
: isTunnel
|
||||
? t("nav.tunnels")
|
||||
: isDocker
|
||||
? t("nav.docker")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: tabType === "rdp" || tabType === "vnc" || tabType === "telnet"
|
||||
? tabType.toUpperCase()
|
||||
: t("nav.terminal"));
|
||||
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tabBaseClasses, "cursor-pointer")}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom:
|
||||
isActive || isSplit ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
{isServer ? (
|
||||
<ServerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isFileManager ? (
|
||||
<FolderIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isTunnel ? (
|
||||
<TunnelIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isDocker ? (
|
||||
<DockerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isUserProfile ? (
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "rdp" ? (
|
||||
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "vnc" ? (
|
||||
<EyeIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : tabType === "telnet" ? (
|
||||
<MessageSquareIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{hasPassword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleCopyPassword}
|
||||
title={getPasswordButtonTitle()}
|
||||
>
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tabType === "terminal" && onOpenFileManager && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFileManager();
|
||||
}}
|
||||
title={t("nav.openFileManager")}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableSplit && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableSplit && onSplit) onSplit();
|
||||
}}
|
||||
disabled={disableSplit}
|
||||
title={
|
||||
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
|
||||
}
|
||||
>
|
||||
<SeparatorVertical
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSplit ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "ssh_manager") {
|
||||
const displayTitle = title || t("nav.sshManager");
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tabBaseClasses, "cursor-pointer")}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "admin") {
|
||||
const displayTitle = title || t("nav.admin");
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tabBaseClasses, "cursor-pointer")}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "network_graph") {
|
||||
const displayTitle = title || t("dashboard.networkGraph");
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tabBaseClasses, "cursor-pointer")}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid var(--foreground)" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<Network className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate text-sm flex-1 min-w-0">{base}</span>
|
||||
{suffix && <span className="text-sm flex-shrink-0">{suffix}</span>}
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSetActiveTab,
|
||||
onCloseTab,
|
||||
onReorderTabs,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
onSetActiveTab: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
onReorderTabs: (tabs: Tab[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||
const tabEls = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const dragData = useRef<{
|
||||
id: string;
|
||||
index: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
offsetX: number;
|
||||
width: number;
|
||||
barTop: number;
|
||||
barHeight: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const dragTargetRef = useRef<number | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabBarRef.current;
|
||||
if (!el) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragTabId) return;
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragData.current || !tabBarRef.current) return;
|
||||
const d = dragData.current;
|
||||
if (Math.abs(e.clientX - d.startX) > 5) didDrag.current = true;
|
||||
|
||||
const barRect = tabBarRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
barRect.left,
|
||||
Math.min(barRect.right - d.width, e.clientX - d.offsetX),
|
||||
);
|
||||
const y = d.barTop;
|
||||
setDragPos({ x, y });
|
||||
|
||||
const centerX = e.clientX - d.offsetX + d.width / 2;
|
||||
let newTarget = d.index;
|
||||
tabEls.current.forEach((el, id) => {
|
||||
if (id === d.id) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mid = rect.left + rect.width / 2;
|
||||
const idx = tabs.findIndex((t) => t.id === id);
|
||||
if (idx < d.index && centerX < mid)
|
||||
newTarget = Math.min(newTarget, idx);
|
||||
if (idx > d.index && centerX > mid)
|
||||
newTarget = Math.max(newTarget, idx);
|
||||
});
|
||||
|
||||
if (tabs[0].type === "dashboard") newTarget = Math.max(1, newTarget);
|
||||
dragTargetRef.current = newTarget;
|
||||
setDragTargetIndex(newTarget);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!dragData.current) return;
|
||||
const { id, index } = dragData.current;
|
||||
const to = dragTargetRef.current ?? index;
|
||||
if (to !== index) {
|
||||
const next = [...tabs];
|
||||
if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]);
|
||||
onReorderTabs(next);
|
||||
}
|
||||
dragData.current = null;
|
||||
dragTargetRef.current = null;
|
||||
setDragTabId(null);
|
||||
setDragTargetIndex(null);
|
||||
setDragPos(null);
|
||||
setTimeout(() => {
|
||||
didDrag.current = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
}, [dragTabId, tabs, onReorderTabs]);
|
||||
|
||||
const dragIdx = tabs.findIndex((t) => t.id === dragTabId);
|
||||
const target = dragTargetIndex ?? dragIdx;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 min-w-0">
|
||||
<div
|
||||
className={`flex items-end bg-sidebar min-w-0 transition-all duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
|
||||
>
|
||||
<div
|
||||
ref={tabBarRef}
|
||||
className="flex h-full flex-1 min-w-0 overflow-x-auto scrollbar-none pl-px"
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeTabId;
|
||||
const isDragging = dragTabId === tab.id;
|
||||
let translateX = 0;
|
||||
if (
|
||||
dragTabId &&
|
||||
!isDragging &&
|
||||
dragIdx !== -1 &&
|
||||
target !== null &&
|
||||
target !== dragIdx
|
||||
) {
|
||||
const draggedWidth =
|
||||
tabEls.current.get(dragTabId)?.offsetWidth ?? 0;
|
||||
if (dragIdx < target && index > dragIdx && index <= target)
|
||||
translateX = -draggedWidth;
|
||||
else if (dragIdx > target && index < dragIdx && index >= target)
|
||||
translateX = draggedWidth;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabEls.current.set(tab.id, el);
|
||||
else tabEls.current.delete(tab.id);
|
||||
}}
|
||||
onClick={() =>
|
||||
!dragTabId && !didDrag.current && onSetActiveTab(tab.id)
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && tab.type !== "dashboard") {
|
||||
e.preventDefault();
|
||||
onCloseTab(tab.id);
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || tab.type === "dashboard") return;
|
||||
e.preventDefault();
|
||||
const el = tabEls.current.get(tab.id);
|
||||
if (!el || !tabBarRef.current) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const barRect = tabBarRef.current.getBoundingClientRect();
|
||||
dragData.current = {
|
||||
id: tab.id,
|
||||
index,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX: e.clientX - rect.left,
|
||||
width: rect.width,
|
||||
barTop: barRect.top,
|
||||
barHeight: barRect.height,
|
||||
x: rect.left,
|
||||
y: barRect.top,
|
||||
};
|
||||
setDragTabId(tab.id);
|
||||
setDragTargetIndex(index);
|
||||
setDragPos({ x: rect.left, y: barRect.top });
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(
|
||||
e.pointerId,
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
transform: isDragging
|
||||
? "none"
|
||||
: `translateX(${translateX}px)`,
|
||||
transition:
|
||||
dragTabId && !isDragging ? "transform 200ms ease" : "none",
|
||||
opacity: isDragging ? 0 : 1,
|
||||
cursor:
|
||||
tab.type === "dashboard"
|
||||
? "pointer"
|
||||
: isDragging
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
className={`group/tab flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm
|
||||
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
|
||||
${
|
||||
tab.type === "dashboard"
|
||||
? `px-2.5 md:px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
: `px-2.5 md:px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
}`}
|
||||
>
|
||||
{tabIcon(tab.type)}
|
||||
{tab.type !== "dashboard" && tab.label}
|
||||
{tab.type !== "dashboard" && (
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
className={`flex items-center justify-center size-5 md:size-4 rounded-sm transition-opacity text-muted-foreground hover:text-foreground hover:bg-muted ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{dragTabId &&
|
||||
dragPos &&
|
||||
(() => {
|
||||
const tab = tabs.find((t) => t.id === dragTabId)!;
|
||||
const active = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: dragPos.x,
|
||||
top: dragPos.y,
|
||||
width: tabEls.current.get(dragTabId)?.offsetWidth,
|
||||
height: tabEls.current.get(dragTabId)?.offsetHeight,
|
||||
pointerEvents: "none",
|
||||
zIndex: 9999,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
className={`flex items-center gap-2 shrink-0 border border-border text-sm shadow-lg
|
||||
${
|
||||
tab.type === "dashboard"
|
||||
? `px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
|
||||
: `px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
|
||||
}`}
|
||||
>
|
||||
{tabIcon(tab.type)}
|
||||
{tab.type !== "dashboard" && tab.label}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center h-full shrink-0 ${open ? "" : "invisible"}`}
|
||||
>
|
||||
<Separator orientation="vertical" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="w-56 border-t-0 [clip-path:inset(0px_-4px_-4px_-4px)] p-0"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => onSetActiveTab(tab.id)}
|
||||
className={`flex items-center justify-between px-2 py-2 text-xs cursor-default hover:bg-accent hover:text-accent-foreground ${tab.id === activeTabId ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{tabIcon(tab.type)}
|
||||
<span className="truncate">
|
||||
{tab.type === "dashboard" ? "Dashboard" : tab.label}
|
||||
</span>
|
||||
</div>
|
||||
{tab.type !== "dashboard" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
className="shrink-0 ml-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<ChevronUp
|
||||
className={`size-4 transition-transform ${open ? "" : "rotate-180"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center justify-center w-full h-6 bg-sidebar border-b border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TabContextTab, TerminalRefHandle } from "@/types/index";
|
||||
|
||||
export type Tab = TabContextTab;
|
||||
|
||||
interface TabContextType {
|
||||
tabs: Tab[];
|
||||
currentTab: number | null;
|
||||
allSplitScreenTab: number[];
|
||||
addTab: (tab: Omit<Tab, "id">) => number;
|
||||
removeTab: (tabId: number) => void;
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
getTab: (tabId: number) => Tab | undefined;
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
updateHostConfig: (
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||
previewTerminalTheme: string | null;
|
||||
setPreviewTerminalTheme: (theme: string | null) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
|
||||
export function useTabs() {
|
||||
const context = useContext(TabContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTabs must be used within a TabProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const NOOP_TABS: TabContextType = {
|
||||
tabs: [],
|
||||
currentTab: null,
|
||||
allSplitScreenTab: [],
|
||||
addTab: () => -1,
|
||||
removeTab: () => {},
|
||||
setCurrentTab: () => {},
|
||||
setSplitScreenTab: () => {},
|
||||
getTab: () => undefined,
|
||||
reorderTabs: () => {},
|
||||
updateHostConfig: () => {},
|
||||
updateTab: () => {},
|
||||
previewTerminalTheme: null,
|
||||
setPreviewTerminalTheme: () => {},
|
||||
};
|
||||
|
||||
export function useTabsSafe(): TabContextType {
|
||||
return useContext(TabContext) ?? NOOP_TABS;
|
||||
}
|
||||
|
||||
interface TabProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function clearTermixSessionStorage() {
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith("termix_session_")) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
export function TabProvider({ children }: TabProviderProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
const shouldRestore = isMobile || isElectron || persistenceEnabled;
|
||||
|
||||
if (!shouldRestore) {
|
||||
return [{ id: 1, type: "home", title: "Home" }];
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem("termix_tabs");
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Tab[];
|
||||
const restored: Tab[] = [{ id: 1, type: "home", title: "Home" }];
|
||||
let maxId = 1;
|
||||
for (const tab of parsed) {
|
||||
if (tab.type === "home") continue;
|
||||
const restoredTab: Tab = {
|
||||
...tab,
|
||||
instanceId: tab.instanceId,
|
||||
terminalRef:
|
||||
tab.type === "terminal"
|
||||
? React.createRef<TerminalRefHandle>()
|
||||
: undefined,
|
||||
hostConfig: tab.hostConfig
|
||||
? {
|
||||
...tab.hostConfig,
|
||||
instanceId: tab.instanceId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
restored.push(restoredTab);
|
||||
if (tab.id > maxId) maxId = tab.id;
|
||||
}
|
||||
if (restored.length > 1) return restored;
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
return [{ id: 1, type: "home", title: "Home" }];
|
||||
});
|
||||
const [currentTab, setCurrentTab] = useState<number>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("termix_currentTab");
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [initialMaxId] = useState(() => {
|
||||
let maxId = 1;
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.id > maxId) maxId = tab.id;
|
||||
});
|
||||
return maxId + 1;
|
||||
});
|
||||
const nextTabId = useRef(initialMaxId);
|
||||
|
||||
useEffect(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
||||
|
||||
if (shouldSave) {
|
||||
const serializable = tabs
|
||||
.filter((t) => t.type !== "home")
|
||||
.map((tab) => {
|
||||
const rest = { ...tab };
|
||||
delete rest.terminalRef;
|
||||
return rest;
|
||||
});
|
||||
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
||||
localStorage.setItem("termix_currentTab", String(currentTab));
|
||||
} else {
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
// Safety net: if currentTab points to a tab that no longer exists, fall back to home
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((t) => t.id === currentTab)) {
|
||||
setCurrentTab(1);
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === 1 && tab.type === "home"
|
||||
? { ...tab, title: t("nav.home") }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
}, [t]);
|
||||
|
||||
function computeUniqueTitle(
|
||||
tabType: Tab["type"],
|
||||
desiredTitle: string | undefined,
|
||||
): string {
|
||||
const defaultTitle =
|
||||
tabType === "server_stats"
|
||||
? t("nav.serverStats")
|
||||
: tabType === "file_manager"
|
||||
? t("nav.fileManager")
|
||||
: tabType === "tunnel"
|
||||
? t("nav.tunnels")
|
||||
: tabType === "docker"
|
||||
? t("nav.docker")
|
||||
: t("nav.terminal");
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
|
||||
const usedNumbers = new Set<number>();
|
||||
let rootUsed = false;
|
||||
tabs.forEach((t) => {
|
||||
if (!t.title) return;
|
||||
if (t.title === root) {
|
||||
rootUsed = true;
|
||||
return;
|
||||
}
|
||||
const m = t.title.match(
|
||||
new RegExp(
|
||||
`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
|
||||
),
|
||||
);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!isNaN(n)) usedNumbers.add(n);
|
||||
}
|
||||
});
|
||||
|
||||
if (!rootUsed) return root;
|
||||
let n = 2;
|
||||
while (usedNumbers.has(n)) n += 1;
|
||||
return `${root} (${n})`;
|
||||
}
|
||||
|
||||
const addTab = (tabData: Omit<Tab, "id">): number => {
|
||||
if (tabData.type === "ssh_manager") {
|
||||
const existingTab = tabs.find((t) => t.type === "ssh_manager");
|
||||
if (existingTab) {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === existingTab.id
|
||||
? {
|
||||
...t,
|
||||
title: existingTab.title,
|
||||
hostConfig: tabData.hostConfig
|
||||
? { ...tabData.hostConfig }
|
||||
: undefined,
|
||||
initialTab: tabData.initialTab,
|
||||
_updateTimestamp: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
setCurrentTab(existingTab.id);
|
||||
setAllSplitScreenTab((prev) =>
|
||||
prev.filter((tid) => tid !== existingTab.id),
|
||||
);
|
||||
return existingTab.id;
|
||||
}
|
||||
}
|
||||
|
||||
const id = nextTabId.current++;
|
||||
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const needsUniqueTitle =
|
||||
tabData.type === "terminal" ||
|
||||
tabData.type === "server_stats" ||
|
||||
tabData.type === "file_manager" ||
|
||||
tabData.type === "tunnel" ||
|
||||
tabData.type === "docker";
|
||||
const effectiveTitle = needsUniqueTitle
|
||||
? computeUniqueTitle(tabData.type, tabData.title)
|
||||
: tabData.title || "";
|
||||
const newTab: Tab = {
|
||||
...tabData,
|
||||
id,
|
||||
instanceId,
|
||||
title: effectiveTitle,
|
||||
terminalRef:
|
||||
tabData.type === "terminal"
|
||||
? React.createRef<TerminalRefHandle>()
|
||||
: undefined,
|
||||
hostConfig: tabData.hostConfig
|
||||
? {
|
||||
...tabData.hostConfig,
|
||||
instanceId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
setTabs((prev) => [...prev, newTab]);
|
||||
setCurrentTab(id);
|
||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||
return id;
|
||||
};
|
||||
|
||||
const pendingCurrentTabRef = useRef<number | null>(null);
|
||||
|
||||
const removeTab = (tabId: number) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (
|
||||
tab &&
|
||||
tab.terminalRef?.current &&
|
||||
typeof tab.terminalRef.current.disconnect === "function"
|
||||
) {
|
||||
tab.terminalRef.current.disconnect();
|
||||
}
|
||||
|
||||
setTabs((prev) => {
|
||||
const closedIndex = prev.findIndex((t) => t.id === tabId);
|
||||
const filtered = prev.filter((t) => t.id !== tabId);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
pendingCurrentTabRef.current = 1;
|
||||
return [{ id: 1, type: "home", title: t("nav.home") }];
|
||||
}
|
||||
|
||||
// If the closed tab was active, compute the next tab to activate
|
||||
// using the latest prev so rapid closes don't use stale data
|
||||
const nextIndex =
|
||||
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
|
||||
pendingCurrentTabRef.current = filtered[Math.max(0, nextIndex)]?.id ?? 1;
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
setAllSplitScreenTab((prev) => {
|
||||
const newSplits = prev.filter((id) => id !== tabId);
|
||||
return newSplits.length <= 1 ? [] : newSplits;
|
||||
});
|
||||
|
||||
setCurrentTab((prevCurrentTab) => {
|
||||
if (prevCurrentTab !== tabId) return prevCurrentTab;
|
||||
return pendingCurrentTabRef.current ?? 1;
|
||||
});
|
||||
};
|
||||
|
||||
const setSplitScreenTab = (tabId: number) => {
|
||||
setAllSplitScreenTab((prev) => {
|
||||
if (prev.includes(tabId)) {
|
||||
return prev.filter((id) => id !== tabId);
|
||||
} else if (prev.length < 6) {
|
||||
return [...prev, tabId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const getTab = (tabId: number) => {
|
||||
return tabs.find((tab) => tab.id === tabId);
|
||||
};
|
||||
|
||||
const isReorderingRef = useRef(false);
|
||||
|
||||
const reorderTabs = (fromIndex: number, toIndex: number) => {
|
||||
if (isReorderingRef.current) return;
|
||||
|
||||
isReorderingRef.current = true;
|
||||
|
||||
setTabs((prev) => {
|
||||
const newTabs = [...prev];
|
||||
const [movedTab] = newTabs.splice(fromIndex, 1);
|
||||
|
||||
const maxIndex = newTabs.length;
|
||||
const safeToIndex = Math.min(toIndex, maxIndex);
|
||||
|
||||
newTabs.splice(safeToIndex, 0, movedTab);
|
||||
|
||||
setTimeout(() => {
|
||||
isReorderingRef.current = false;
|
||||
}, 100);
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
};
|
||||
|
||||
const updateHostConfig = useCallback(
|
||||
(
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => {
|
||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||
if (tab.type === "ssh_manager") {
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: {
|
||||
...newHostConfig,
|
||||
instanceId: tab.hostConfig.instanceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: {
|
||||
...newHostConfig,
|
||||
instanceId: tab.hostConfig.instanceId,
|
||||
},
|
||||
title: newHostConfig.name?.trim()
|
||||
? newHostConfig.name
|
||||
: t("nav.hostTabTitle", {
|
||||
username: newHostConfig.username,
|
||||
ip: newHostConfig.ip,
|
||||
port: newHostConfig.port,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const updateTab = useCallback(
|
||||
(tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId
|
||||
? { ...tab, ...updates, _updateTimestamp: Date.now() }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value: TabContextType = useMemo(
|
||||
() => ({
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
addTab,
|
||||
removeTab,
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
addTab,
|
||||
removeTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
],
|
||||
);
|
||||
|
||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Box,
|
||||
FolderSearch,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
User,
|
||||
Activity,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||
import { Terminal as TerminalFeature } from "@/features/terminal/Terminal";
|
||||
import { FileManager } from "@/features/file-manager/FileManager";
|
||||
import { DockerManager } from "@/features/docker/DockerManager";
|
||||
import { ServerStats } from "@/features/server-stats/ServerStats";
|
||||
import GuacamoleApp from "@/features/guacamole/GuacamoleApp";
|
||||
import { DashboardTab } from "@/dashboard/DashboardTab";
|
||||
import { TunnelTab } from "@/features/tunnel/TunnelTab";
|
||||
import type { Tab, TabType, Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
function hostToSSHHost(h: Host): SSHHost {
|
||||
return {
|
||||
id: parseInt(h.id, 10),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
key: h.key,
|
||||
keyPassword: h.keyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
|
||||
terminalConfig: h.terminalConfig,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
showDockerInSidebar: true,
|
||||
showServerStatsInSidebar: true,
|
||||
defaultPath: h.defaultPath ?? "",
|
||||
tunnelConnections: [],
|
||||
connectionType: "ssh",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as SSHHost;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
messageKey,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
messageKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
<Icon className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t(messageKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function tabIcon(type: TabType) {
|
||||
switch (type) {
|
||||
case "dashboard":
|
||||
return <LayoutDashboard className="size-3.5" />;
|
||||
case "terminal":
|
||||
return <Terminal className="size-3.5" />;
|
||||
case "rdp":
|
||||
return <Monitor className="size-3.5" />;
|
||||
case "vnc":
|
||||
return <Monitor className="size-3.5" />;
|
||||
case "telnet":
|
||||
return <Terminal className="size-3.5" />;
|
||||
case "stats":
|
||||
return <Server className="size-3.5" />;
|
||||
case "files":
|
||||
return <FolderSearch className="size-3.5" />;
|
||||
case "host-manager":
|
||||
return <Server className="size-3.5" />;
|
||||
case "user-profile":
|
||||
return <User className="size-3.5" />;
|
||||
case "admin-settings":
|
||||
return <Settings className="size-3.5" />;
|
||||
case "docker":
|
||||
return <Box className="size-3.5" />;
|
||||
case "tunnel":
|
||||
return <Network className="size-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTabContent(
|
||||
tab: Tab,
|
||||
onOpenSingletonTab?: (type: TabType) => void,
|
||||
onOpenTab?: (host: Host, type: TabType) => void,
|
||||
) {
|
||||
const { host, label } = tab;
|
||||
|
||||
switch (tab.type) {
|
||||
case "dashboard":
|
||||
return (
|
||||
<DashboardTab
|
||||
onOpenSingletonTab={onOpenSingletonTab!}
|
||||
onOpenTab={onOpenTab!}
|
||||
/>
|
||||
);
|
||||
|
||||
case "terminal":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={TerminalSquare}
|
||||
messageKey="terminal.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<CommandHistoryProvider>
|
||||
<TerminalFeature
|
||||
hostConfig={
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
} as any
|
||||
}
|
||||
isVisible={true}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
);
|
||||
|
||||
case "files":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FolderSearch}
|
||||
messageKey="fileManager.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return <FileManager initialHost={hostToSSHHost(host)} />;
|
||||
|
||||
case "docker":
|
||||
if (!host)
|
||||
return <EmptyState icon={Box} messageKey="docker.noHostSelected" />;
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostToSSHHost(host)}
|
||||
title={label}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case "stats":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState icon={Activity} messageKey="serverStats.noHostSelected" />
|
||||
);
|
||||
return (
|
||||
<ServerStats
|
||||
hostConfig={hostToSSHHost(host) as any}
|
||||
title={label}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tunnel":
|
||||
return <TunnelTab label={label} host={host} />;
|
||||
|
||||
case "rdp":
|
||||
case "vnc":
|
||||
case "telnet":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState icon={Monitor} messageKey="guacamole.noHostSelected" />
|
||||
);
|
||||
return <GuacamoleApp hostId={host.id} />;
|
||||
|
||||
case "host-manager":
|
||||
case "user-profile":
|
||||
case "admin-settings":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user