feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
+395
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+477
View File
@@ -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>
);
}
+442
View File
@@ -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;
}
+336
View File
@@ -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>
);
}
+479
View File
@@ -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>;
}
+206
View File
@@ -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;
}
}