feat: continued general UI improvements

This commit is contained in:
LukeGus
2026-05-26 22:05:58 -05:00
parent 0fe3f8d126
commit 9c12a84a91
15 changed files with 867 additions and 505 deletions
+16 -3
View File
@@ -2697,9 +2697,22 @@ app.post(
res.json({ message: "Connection request received", tunnelName });
operation.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
operation
.catch((err) => {
tunnelLogger.error("Tunnel operation failed", err, {
operation: "tunnel_operation_failed",
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: err instanceof Error ? err.message : "Unknown error",
});
tunnelConnecting.delete(tunnelName);
})
.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
} catch (error) {
tunnelLogger.error("Failed to process tunnel connect", error, {
operation: "tunnel_connect",
+2 -1
View File
@@ -145,7 +145,8 @@ function App() {
})
.catch(() => {});
}
setPhase("idle-app");
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})
.catch(() => {
clearStoredAuth();
+143 -105
View File
@@ -20,6 +20,7 @@ import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
import { SplitView } from "@/shell/SplitView";
import { renderTabContent } from "@/shell/tabUtils";
import { TabBar } from "@/shell/TabBar";
import type {
Tab,
@@ -111,7 +112,6 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
return root;
}
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
import { renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ────────────────────────────────────────────────────────────────
@@ -303,7 +303,7 @@ export function AppShell({
// ─── Tab management ──────────────────────────────────────────────────────
function openTab(host: Host, type: TabType) {
const openTab = useCallback(function openTab(host: Host, type: TabType) {
const tabId = `${host.name}-${type}-${Date.now()}`;
const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
@@ -327,7 +327,7 @@ export function AppShell({
return [...next, { id: tabId, type, label, host, terminalRef: ref }];
});
setActiveTabId(tabId);
}
}, []);
function connectHost(host: Host, preferredType?: TabType) {
const type: TabType =
@@ -344,47 +344,52 @@ export function AppShell({
openTab(host, type);
}
function openSingletonTab(type: TabType, pendingEvent?: string) {
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
setSidebarOpen(true);
setRailView("credentials");
setTimeout(
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
),
0,
);
} else {
setSidebarOpen(true);
setRailView("hosts");
if (pendingEvent) {
const openSingletonTab = useCallback(
function openSingletonTab(type: TabType, pendingEvent?: string) {
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
setSidebarOpen(true);
setRailView("credentials");
setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
),
0,
);
} else {
setSidebarOpen(true);
setRailView("hosts");
if (pendingEvent) {
setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
0,
);
}
}
return;
}
return;
}
if (type === "user-profile" || type === "admin-settings") {
handleRailClick(type as RailView);
return;
}
const id = type;
setTabs((prev) => {
if (prev.find((t) => t.id === id)) return prev;
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
docker: t("nav.docker"),
tunnel: t("nav.tunnels"),
network_graph: t("nav.networkGraph"),
};
return [...prev, { id, type, label: singletonLabels[type] ?? type }];
});
setActiveTabId(id);
}
if (type === "user-profile" || type === "admin-settings") {
setSidebarEditing(false);
setRailView(type as RailView);
setSidebarOpen(true);
return;
}
const id = type;
setTabs((prev) => {
if (prev.find((t) => t.id === id)) return prev;
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
docker: t("nav.docker"),
tunnel: t("nav.tunnels"),
network_graph: t("nav.networkGraph"),
};
return [...prev, { id, type, label: singletonLabels[type] ?? type }];
});
setActiveTabId(id);
},
[t],
);
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
@@ -483,6 +488,24 @@ export function AppShell({
[sidebarWidth],
);
// Resize all terminals in panes + active terminal when split mode or sidebar changes
const resizeAllTerminals = useCallback(() => {
const id = requestAnimationFrame(() => {
tabs.forEach((tab) => {
if (!tab.terminalRef) return;
const ref = tab.terminalRef.current as any;
ref?.fit?.();
ref?.notifyResize?.();
});
});
return id;
}, [tabs]);
useEffect(() => {
const id = resizeAllTerminals();
return () => cancelAnimationFrame(id);
}, [splitMode, sidebarWidth, sidebarOpen]);
const activeTab = tabs.find((t) => t.id === activeTabId)!;
const isSplit = splitMode !== "none";
const terminalTabs = tabs.filter((t) => t.type === "terminal");
@@ -502,13 +525,17 @@ export function AppShell({
hostTree={realHostTree ?? undefined}
loading={hostsLoading}
onEditingChange={setSidebarEditing}
active={railView === "hosts"}
/>
</div>
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
>
<CredentialsPanel onEditingChange={setSidebarEditing} />
<CredentialsPanel
onEditingChange={setSidebarEditing}
active={railView === "credentials"}
/>
</div>
{railView === "quick-connect" && (
@@ -615,6 +642,7 @@ export function AppShell({
profileDropdownOpen={profileDropdownOpen}
onProfileDropdownChange={setProfileDropdownOpen}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onLogout={onLogout}
/>
@@ -677,72 +705,82 @@ export function AppShell({
onReorderTabs={setTabs}
/>
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{isSplit && !isMobile ? (
<SplitView
tabs={tabs}
paneTabIds={paneTabIds}
splitMode={splitMode}
onOpenSingletonTab={openSingletonTab}
onOpenTab={openTab}
/>
) : (
<>
{/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */}
{tabs
.filter((tab) => tab.type === "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="absolute inset-0 overflow-hidden"
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: visible ? 1 : 0,
}}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
{/* Non-terminal tabs: absolutely positioned above terminals when active */}
{tabs
.filter((tab) => tab.type !== "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="flex flex-col overflow-hidden bg-background"
style={
visible
? {
position: "absolute",
inset: 0,
zIndex: 2,
}
: { display: "none" }
}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
</>
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
{!isMobile && (
<div
className="absolute inset-0"
style={{
display: isSplit ? "flex" : "none",
flexDirection: "column",
}}
>
<SplitView
tabs={tabs}
paneTabIds={paneTabIds}
splitMode={splitMode}
onOpenSingletonTab={openSingletonTab}
onOpenTab={openTab}
onTerminalResize={resizeAllTerminals}
/>
</div>
)}
{/* Normal tab view — always mounted, hidden via CSS when split is active */}
<div
className="absolute inset-0 flex flex-col"
style={{ display: isSplit && !isMobile ? "none" : "flex" }}
>
{/* Terminal tabs: always in DOM, visibility-toggled so xterm keeps its dimensions */}
{tabs
.filter((tab) => tab.type === "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="absolute inset-0 overflow-hidden"
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: visible ? 1 : 0,
}}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
{/* Non-terminal tabs */}
{tabs
.filter((tab) => tab.type !== "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="flex flex-col overflow-hidden bg-background"
style={
visible
? { position: "absolute", inset: 0, zIndex: 2 }
: { display: "none" }
}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
</div>
</div>
</div>
+55 -20
View File
@@ -111,6 +111,9 @@ function resolveCssVar(varName: string, fallback: string): string {
return resolved && resolved !== "rgba(0, 0, 0, 0)" ? resolved : fallback;
}
const NODE_W = 220;
const NODE_H = 88;
function buildNodeSvg(
name: string,
ip: string,
@@ -130,30 +133,50 @@ function buildNodeSvg(
const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9");
const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8");
const esc = (s: string) =>
s.replace(
const dpr = Math.min(window.devicePixelRatio || 1, 3);
const W = NODE_W * dpr;
const H = NODE_H * dpr;
const s = dpr;
const esc = (str: string) =>
str.replace(
/[<>&"]/g,
(c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c] ?? c,
);
const tagsHtml = tags
.slice(0, 2)
.slice(0, 3)
.map(
(tag) =>
`<span style="background:${statusColor};color:#fff;padding:1px 6px;border-radius:0;font-size:8px;font-weight:700;margin:0 1px;">${esc(tag)}</span>`,
`<span style="background:${statusColor};color:#fff;padding:${1 * s}px ${5 * s}px;border-radius:${2 * s}px;font-size:${8 * s}px;font-weight:700;margin:0 ${1 * s}px;white-space:nowrap;">${esc(tag)}</span>`,
)
.join("");
const serverIcon = `<path d="M${2 * s} ${2 * s} h${14 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} ${1 * s} v${5 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} ${1 * s} h-${14 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} -${1 * s} v-${5 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} -${1 * s}z" fill="none" stroke="${textSecondary}" stroke-width="${0.8 * s}" opacity="0.4"/>
<circle cx="${14 * s}" cy="${5.5 * s}" r="${1.5 * s}" fill="${statusColor}" opacity="0.8"/>`;
return (
"data:image/svg+xml;utf8," +
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="180" height="80" viewBox="0 0 180 80">
<rect x="0" y="0" width="180" height="80" rx="0" fill="${bg}" stroke="${border}" stroke-width="1"/>
<rect x="0" y="0" width="4" height="80" fill="${statusColor}"/>
<text x="14" y="28" font-family="monospace,sans-serif" font-size="12" font-weight="700" fill="${textPrimary}" xml:space="preserve">${esc(name).substring(0, 18)}</text>
<text x="14" y="46" font-family="monospace,sans-serif" font-size="10" fill="${textSecondary}" xml:space="preserve">${esc(ip)}</text>
${tagsHtml ? `<foreignObject x="12" y="54" width="160" height="20"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;gap:2px;">${tagsHtml}</div></foreignObject>` : ""}
</svg>`)
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="${1 * s}" stdDeviation="${2 * s}" flood-color="#000" flood-opacity="0.25"/>
</filter>
<clipPath id="nameClip">
<rect x="${14 * s}" y="${10 * s}" width="${175 * s}" height="${20 * s}"/>
</clipPath>
</defs>
<rect x="0" y="0" width="${W}" height="${H}" rx="${3 * s}" fill="${bg}" stroke="${border}" stroke-width="${s}" filter="url(#shadow)"/>
<rect x="0" y="0" width="${5 * s}" height="${H}" rx="${3 * s}" fill="${statusColor}"/>
<rect x="0" y="0" width="${5 * s}" height="${H}" fill="${statusColor}"/>
<g transform="translate(${W - 20 * s} ${3 * s})">${serverIcon}</g>
<text x="${14 * s}" y="${28 * s}" font-family="-apple-system,BlinkMacSystemFont,system-ui,sans-serif" font-size="${13 * s}" font-weight="700" fill="${textPrimary}" xml:space="preserve" clip-path="url(#nameClip)">${esc(name)}</text>
<text x="${14 * s}" y="${46 * s}" font-family="ui-monospace,monospace,sans-serif" font-size="${11 * s}" fill="${textSecondary}" xml:space="preserve">${esc(ip)}</text>
${tagsHtml ? `<foreignObject x="${12 * s}" y="${56 * s}" width="${196 * s}" height="${24 * s}"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;gap:${2 * s}px;flex-wrap:nowrap;overflow:hidden;">${tagsHtml}</div></foreignObject>` : ""}
</svg>`,
)
);
}
@@ -225,9 +248,19 @@ export function NetworkGraphCard({
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p));
};
document.addEventListener("mousedown", onClickOutside, true);
const themeObserver = new MutationObserver(() => {
if (cyRef.current) applyStyle(cyRef.current);
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => {
if (statusIntervalRef.current) clearInterval(statusIntervalRef.current);
document.removeEventListener("mousedown", onClickOutside, true);
themeObserver.disconnect();
};
}, []);
@@ -357,12 +390,14 @@ export function NetworkGraphCard({
}, [loading]);
const applyStyle = useCallback((cy: cytoscape.Core) => {
const edgeColor = resolveCssVar("--border", "#4a4a4e");
const mutedFg = resolveCssVar("--muted-foreground", "#94a3b8");
cy.style()
.selector("node")
.style({
label: "",
width: "180px",
height: "80px",
width: `${NODE_W}px`,
height: `${NODE_H}px`,
shape: "rectangle",
"border-width": "0px",
"background-opacity": 0,
@@ -387,16 +422,16 @@ export function NetworkGraphCard({
"text-valign": "top",
"text-halign": "center",
"text-margin-y": -6,
color: "#94a3b8",
color: mutedFg,
"font-size": "13px",
"font-weight": "bold",
shape: "rectangle",
padding: "12px",
padding: "20px",
})
.selector("edge")
.style({
width: "1.5px",
"line-color": "#3a3a3c",
"line-color": edgeColor,
"curve-style": "bezier",
"target-arrow-shape": "none",
})
@@ -816,18 +851,18 @@ export function NetworkGraphCard({
const cytoscapeEl = (
<div
ref={cyContainerRef}
className="relative flex-1 min-h-0 w-full overflow-hidden bg-card"
className="relative flex-1 min-h-0 w-full overflow-hidden bg-background"
onContextMenu={(e) => e.preventDefault()}
>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-card/80">
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{contextMenuEl}
<CytoscapeComponent
elements={elements}
style={{ width: "100%", height: "100%" }}
style={{ width: "100%", height: "100%", background: "transparent" }}
layout={{ name: "preset" }}
cy={handleNodeInit}
wheelSensitivity={1.5}
@@ -1153,7 +1188,7 @@ export function NetworkGraphCard({
if (!embedded) {
return (
<div className="h-full w-full flex flex-col bg-card">
<div className="h-full w-full flex flex-col bg-background">
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0 flex-wrap">
<div className="flex items-center gap-0.5">
<Button
+49 -11
View File
@@ -94,6 +94,7 @@ function DockerManagerInner({
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
@@ -278,7 +279,7 @@ function DockerManagerInner({
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
@@ -539,6 +540,15 @@ function DockerManagerInner({
onClose?.();
};
const handleRetry = () => {
initializingRef.current = false;
setSessionId(null);
setHasConnectionError(false);
setDockerValidation(null);
clearLogs();
setRetryCount((c) => c + 1);
};
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
const bottomMarginPx = 8;
@@ -610,21 +620,32 @@ function DockerManagerInner({
}
if (dockerValidation && !dockerValidation.available) {
const isError =
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
{!isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
hasConnectionError={isError}
position={isError ? "top" : "bottom"}
/>
</div>
);
@@ -770,6 +791,23 @@ function DockerManagerInner({
visible={isConnecting && !isConnectionLogExpanded}
message={t("docker.connecting")}
/>
{hasConnectionError && !isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
@@ -2981,6 +2981,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
@@ -2990,6 +2991,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
@@ -3004,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
+31 -16
View File
@@ -405,6 +405,11 @@ function ServerStatsInner({
try {
if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
@@ -459,10 +464,10 @@ function ServerStatsInner({
if (data) {
setMetrics(data);
setServerStatus("online");
if (!hasExistingMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
setTimeout(() => clearLogs(), 1000);
}
}
@@ -567,17 +572,24 @@ function ServerStatsInner({
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (hasConnectionError) {
setHasConnectionError(false);
clearLogs();
return;
}
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === "online" ? "online" : "offline");
const data = await getServerMetricsById(currentHostConfig.id);
if (data) setMetrics(data);
setShowStatsUI(true);
if (data) {
setMetrics(data);
setShowStatsUI(true);
}
} catch {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
@@ -598,7 +610,7 @@ function ServerStatsInner({
}}
>
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
{!totpRequired && !isLoadingMetrics && (
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
@@ -725,16 +737,19 @@ function ServerStatsInner({
showStatsUI &&
!isLoadingMetrics &&
!metrics &&
serverStatus === "offline" && (
serverStatus === "offline" &&
!hasConnectionError && (
<div className="flex-1 flex items-center justify-center py-20">
<div className="text-center opacity-40">
<Server className="size-16 mx-auto mb-4" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("serverStats.cannotFetchMetrics")}
</p>
<div className="text-center">
<div className="opacity-40">
<Server className="size-16 mx-auto mb-4" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
</div>
)}
@@ -767,7 +782,7 @@ function ServerStatsInner({
/>
<ConnectionLog
isConnecting={isLoadingMetrics}
isConnected={serverStatus === "online"}
isConnected={serverStatus === "online" && !hasConnectionError}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
+4 -1
View File
@@ -1183,6 +1183,8 @@
"connecting": "Connecting...",
"connectionFailed": "Failed to connect to server",
"naCpus": "N/A CPU(s)",
"cpuCores_one": "{{count}} Core",
"cpuCores_other": "{{count}} Cores",
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
"diskUsage": "Disk Usage",
@@ -1229,7 +1231,8 @@
"loadAvg": "Load Avg",
"swap": "Swap",
"architecture": "Architecture",
"refresh": "Refresh"
"refresh": "Refresh",
"retry": "Retry"
},
"auth": {
"tagline": "SSH SERVER MANAGER",
+282 -99
View File
@@ -1,15 +1,13 @@
import { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, memo } from "react";
import { useTranslation } from "react-i18next";
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
import { renderTabContent, tabIcon } from "@/shell/tabUtils";
import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types";
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── useSplitSizes ────────────────────────────────────────────────────────────
type RowColSizes = number[][];
// ─── useSplitSizes ────────────────────────────────────────────────────────────
function defaultSizes(mode: SplitMode): {
rowSizes: number[];
rowColSizes: RowColSizes;
@@ -73,7 +71,6 @@ function useSplitSizes(splitMode: SplitMode) {
splitDragState.active = true;
setIsDragging(true);
}
function endDrag() {
splitDragState.active = false;
setIsDragging(false);
@@ -87,11 +84,13 @@ function useSplitSizes(splitMode: SplitMode) {
startDrag();
const totalH = container.getBoundingClientRect().height;
const startY = e.clientY;
const a = rowSizes[rowIdx];
const b = rowSizes[rowIdx + 1];
const a = rowSizes[rowIdx],
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));
const na = Math.max(
10,
Math.min(a + b - 10, a + ((ev.clientY - startY) / totalH) * 100),
);
setRowSizes((prev) => {
const n = [...prev];
n[rowIdx] = na;
@@ -114,11 +113,16 @@ function useSplitSizes(splitMode: SplitMode) {
startDrag();
const totalH = container.getBoundingClientRect().height;
const startY = e.touches[0].clientY;
const a = rowSizes[rowIdx];
const b = rowSizes[rowIdx + 1];
const a = rowSizes[rowIdx],
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));
const na = Math.max(
10,
Math.min(
a + b - 10,
a + ((ev.touches[0].clientY - startY) / totalH) * 100,
),
);
setRowSizes((prev) => {
const n = [...prev];
n[rowIdx] = na;
@@ -143,11 +147,13 @@ function useSplitSizes(splitMode: SplitMode) {
const totalW = container.getBoundingClientRect().width;
const startX = e.clientX;
const cols = rowColSizes[rowIdx];
const a = cols[colIdx];
const b = cols[colIdx + 1];
const a = cols[colIdx],
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));
const na = Math.max(
10,
Math.min(a + b - 10, a + ((ev.clientX - startX) / totalW) * 100),
);
setRowColSizes((prev) => {
const next = prev.map((r) => [...r]);
next[rowIdx][colIdx] = na;
@@ -175,11 +181,16 @@ function useSplitSizes(splitMode: SplitMode) {
const totalW = container.getBoundingClientRect().width;
const startX = e.touches[0].clientX;
const cols = rowColSizes[rowIdx];
const a = cols[colIdx];
const b = cols[colIdx + 1];
const a = cols[colIdx],
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));
const na = Math.max(
10,
Math.min(
a + b - 10,
a + ((ev.touches[0].clientX - startX) / totalW) * 100,
),
);
setRowColSizes((prev) => {
const next = prev.map((r) => [...r]);
next[rowIdx][colIdx] = na;
@@ -219,12 +230,14 @@ function ColDivider({
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 className="relative w-0 shrink-0 z-10">
<div
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
className="absolute inset-y-0 -left-2 -right-2 cursor-col-resize flex items-center justify-center group"
>
<div className="w-px h-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
</div>
</div>
);
}
@@ -237,12 +250,14 @@ function RowDivider({
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 className="relative h-0 w-full shrink-0 z-10">
<div
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
className="absolute inset-x-0 -top-2 -bottom-2 cursor-row-resize flex flex-col items-center justify-center group"
>
<div className="h-px w-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
</div>
</div>
);
}
@@ -290,7 +305,19 @@ function EmptyPane() {
);
}
function Pane({
const PaneContent = memo(function PaneContent({
tab,
onOpenSingletonTab,
onOpenTab,
}: {
tab: Tab;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
}) {
return <>{renderTabContent(tab, onOpenSingletonTab, onOpenTab)}</>;
});
const Pane = memo(function Pane({
tab,
paneIndex,
isDragging,
@@ -308,7 +335,12 @@ function Pane({
<PaneHeader tab={tab} paneIndex={paneIndex} />
<div className="flex-1 min-h-0 overflow-hidden">
{tab ? (
renderTabContent(tab, onOpenSingletonTab, onOpenTab)
<PaneContent
key={tab.id}
tab={tab}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
) : (
<EmptyPane />
)}
@@ -318,22 +350,89 @@ function Pane({
)}
</div>
);
}
});
// ─── Row (top-level so React never sees a new component type) ─────────────────
const Row = memo(function Row({
rowIdx,
paneIndices,
rowHeight,
colWidths,
paneTabIds,
tabs,
isDragging,
onOpenSingletonTab,
onOpenTab,
onColDivider,
onColDividerTouch,
}: {
rowIdx: number;
paneIndices: number[];
rowHeight: number;
colWidths: number[];
paneTabIds: (string | null)[];
tabs: Tab[];
isDragging: boolean;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void;
onColDividerTouch: (
e: React.TouchEvent,
rowIdx: number,
colIdx: number,
) => void;
}) {
const widths = colWidths ?? [];
return (
<div className="flex min-h-0 w-full" style={{ height: `${rowHeight}%` }}>
{paneIndices.map((pIdx, ci) => {
const tabId = paneTabIds[pIdx];
const tab =
tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
return (
<React.Fragment key={pIdx}>
<div
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${widths[ci] ?? 100 / paneIndices.length}%` }}
>
<Pane
tab={tab}
paneIndex={pIdx}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
{ci < paneIndices.length - 1 && (
<ColDivider
onMouseDown={(e) => onColDivider(e, rowIdx, ci)}
onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)}
/>
)}
</React.Fragment>
);
})}
</div>
);
});
// ─── SplitView ────────────────────────────────────────────────────────────────
export function SplitView({
export const SplitView = memo(function SplitView({
tabs,
paneTabIds,
splitMode,
onOpenSingletonTab,
onOpenTab,
onTerminalResize,
}: {
tabs: Tab[];
paneTabIds: (string | null)[];
splitMode: SplitMode;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onTerminalResize?: () => void;
}) {
const {
rowSizes,
@@ -347,55 +446,17 @@ export function SplitView({
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}
/>
);
}
useEffect(() => {
if (!isDragging) {
const id = requestAnimationFrame(() => onTerminalResize?.());
return () => cancelAnimationFrame(id);
}
}, [isDragging, onTerminalResize]);
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>
);
// Inline pane lookup for the non-Row layouts (3-way, 3-way-horizontal)
function tab(idx: number): Tab | null {
const tabId = paneTabIds[idx];
return tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
}
return (
@@ -411,7 +472,21 @@ export function SplitView({
Reset
</button>
{splitMode === "2-way" && <Row rowIdx={0} paneIndices={[0, 1]} />}
{splitMode === "2-way" && (
<Row
rowIdx={0}
paneIndices={[0, 1]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
)}
{splitMode === "3-way" && (
<div className="flex w-full h-full min-h-0">
@@ -419,7 +494,13 @@ export function SplitView({
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${rowColSizes[0][0]}%` }}
>
{pane(0)}
<Pane
tab={tab(0)}
paneIndex={0}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
<ColDivider
onMouseDown={(e) => onColDivider(e, 0, 0)}
@@ -430,7 +511,13 @@ export function SplitView({
className="min-h-0 overflow-hidden"
style={{ height: `${rowSizes[0]}%` }}
>
{pane(1)}
<Pane
tab={tab(1)}
paneIndex={1}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
<RowDivider
onMouseDown={(e) => onRowDivider(e, 0)}
@@ -440,7 +527,13 @@ export function SplitView({
className="min-h-0 overflow-hidden"
style={{ height: `${rowSizes[1]}%` }}
>
{pane(2)}
<Pane
tab={tab(2)}
paneIndex={2}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
</div>
</div>
@@ -456,14 +549,26 @@ export function SplitView({
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${rowColSizes[0][0]}%` }}
>
{pane(0)}
<Pane
tab={tab(0)}
paneIndex={0}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
<ColDivider
onMouseDown={(e) => onColDivider(e, 0, 0)}
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
/>
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
{pane(1)}
<Pane
tab={tab(1)}
paneIndex={1}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
</div>
<RowDivider
@@ -471,43 +576,121 @@ export function SplitView({
onTouchStart={(e) => onRowDividerTouch(e, 0)}
/>
<div className="flex-1 min-h-0 overflow-hidden">
{pane(2)}
<Pane
tab={tab(2)}
paneIndex={2}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
</div>
)}
{splitMode === "4-way" && (
<div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1]} />
<Row
rowIdx={0}
paneIndices={[0, 1]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider
onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)}
/>
<Row rowIdx={1} paneIndices={[2, 3]} />
<Row
rowIdx={1}
paneIndices={[2, 3]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div>
)}
{splitMode === "5-way" && (
<div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1, 2]} />
<Row
rowIdx={0}
paneIndices={[0, 1, 2]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider
onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)}
/>
<Row rowIdx={1} paneIndices={[3, 4]} />
<Row
rowIdx={1}
paneIndices={[3, 4]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div>
)}
{splitMode === "6-way" && (
<div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1, 2]} />
<Row
rowIdx={0}
paneIndices={[0, 1, 2]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider
onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)}
/>
<Row rowIdx={1} paneIndices={[3, 4, 5]} />
<Row
rowIdx={1}
paneIndices={[3, 4, 5]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div>
)}
</div>
);
}
});
+33 -1
View File
@@ -5,6 +5,7 @@ import {
Hammer,
KeyRound,
LayoutPanelLeft,
Network,
Play,
Server,
Settings,
@@ -17,7 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { SplitMode, ToolsTab } from "@/types/ui-types";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
export type RailView =
| "hosts"
@@ -35,6 +36,7 @@ type RailItem =
title: string;
dot?: boolean;
}
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" };
function buildRailButtons(
@@ -68,6 +70,13 @@ function buildRailButtons(
dot: splitMode !== "none",
},
{ kind: "separator" },
{
kind: "tab",
tabType: "network_graph" as TabType,
icon: <Network size={16} />,
title: t("nav.networkGraph"),
},
{ kind: "separator" },
];
}
@@ -84,6 +93,7 @@ export function AppRail({
profileDropdownOpen,
onProfileDropdownChange,
onRailClick,
onOpenTab,
onLogout,
}: {
railView: RailView;
@@ -94,6 +104,7 @@ export function AppRail({
profileDropdownOpen: boolean;
onProfileDropdownChange: (open: boolean) => void;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
@@ -116,6 +127,27 @@ export function AppRail({
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
) : item.kind === "tab" ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
+3
View File
@@ -5,8 +5,10 @@ import { HostManager } from "@/sidebar/HostManager";
export function CredentialsPanel({
onEditingChange,
active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
@@ -58,6 +60,7 @@ export function CredentialsPanel({
hideListHeader
externalSearch={managerEditing ? undefined : search}
onEditingChange={handleEditingChange}
active={active}
/>
</div>
</div>
+7 -13
View File
@@ -4328,16 +4328,7 @@ function CredentialEditorView({
{["password", "key"].map((m) => (
<button
key={m}
onClick={() =>
setCredForm((p) => ({
...p,
type: m as any,
value: "",
publicKey: "",
certPublicKey: "",
passphrase: "",
}))
}
onClick={() => setCredField("type", m as any)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "key"
@@ -4643,12 +4634,14 @@ export function HostManager({
onEditingChange,
hideListHeader,
externalSearch,
active = true,
}: {
pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void;
hideListHeader?: boolean;
externalSearch?: string;
active?: boolean;
} = {}) {
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
@@ -4760,6 +4753,7 @@ export function HostManager({
}, [pendingEditId, pendingAction]);
useEffect(() => {
if (!active) return;
const handleAddHost = () => {
setEditingHost("new");
setEditingCredential(null);
@@ -4803,7 +4797,7 @@ export function HostManager({
);
window.removeEventListener("host-manager:edit-host", handleEditHost);
};
}, []);
}, [active]);
const allHosts = hosts;
const filteredCredentials = credentials.filter(
@@ -4958,8 +4952,8 @@ export function HostManager({
const isEditing = !!editingHost || !!editingCredential;
useEffect(() => {
onEditingChange?.(isEditing);
}, [isEditing]);
if (active) onEditingChange?.(isEditing);
}, [isEditing, active]);
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
+3 -1
View File
@@ -30,12 +30,14 @@ export function HostsPanel({
hostTree,
loading,
onEditingChange,
active = true,
}: {
onOpenTab: (host: Host, type: TabType) => void;
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
loading?: boolean;
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
const { t } = useTranslation();
const [hostSearch, setHostSearch] = useState("");
@@ -325,7 +327,7 @@ export function HostsPanel({
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} />
<HostManager onEditingChange={handleEditingChange} active={active} />
</div>
<HostShareModal
+234 -233
View File
@@ -280,7 +280,7 @@ export function HostItem({
{/* Action tray — slides open on CSS hover or while menu is open */}
<div
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[200px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[200px] !opacity-100" : ""}`}
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[300px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
>
{host.online &&
((host.cpu != null && host.cpu > 0) ||
@@ -317,257 +317,258 @@ export function HostItem({
</div>
)}
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
onClick={(e) => {
e.stopPropagation();
onOpenTab(type);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Icon className="size-3.5" />
</button>
))}
{host.enableSsh &&
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 pt-1.5 pl-2 pb-1">
{/* Connection buttons — wrap naturally to a second line */}
<div className="flex items-center flex-wrap gap-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
onClick={(e) => {
e.stopPropagation();
onOpenTab(type);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Icon className="size-3.5" />
</button>
))}
{host.enableSsh &&
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
onOpenTab("rdp");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
onOpenTab("rdp");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableVnc && (
<button
title="VNC"
onClick={(e) => {
e.stopPropagation();
onOpenTab("vnc");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableTelnet && (
<button
title="Telnet"
onClick={(e) => {
e.stopPropagation();
onOpenTab("telnet");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Terminal className="size-3.5" />
</button>
)}
{onEditHost && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
{host.enableVnc && (
<button
title="VNC"
onClick={(e) => {
e.stopPropagation();
onOpenTab("vnc");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableTelnet && (
<button
title="Telnet"
onClick={(e) => {
e.stopPropagation();
onOpenTab("telnet");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Terminal className="size-3.5" />
</button>
)}
</div>
{/* Separator + management buttons row — always fixed position */}
<div className="flex items-center gap-1 pt-0.5 border-t border-border/40 mt-0.5">
{onEditHost && (
<button
title="Edit Host"
onClick={(e) => {
e.stopPropagation();
onEditHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors"
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
</>
)}
{onShareHost && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{onShareHost && (
<button
title={t("hosts.shareHost")}
onClick={(e) => {
e.stopPropagation();
onShareHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors"
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Share2 className="size-3.5" />
</button>
</>
)}
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
title="More options"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${host.username}@${host.ip}`,
);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Link className="size-3.5 mr-2" />
{t("hosts.copyLink")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{host.enableSsh && host.enableTerminal && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=terminal&hostId=${host.id}`,
);
toast.success(t("hosts.terminalUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTerminalUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=file-manager&hostId=${host.id}`,
);
toast.success(t("hosts.fileManagerUrlCopied"));
}}
>
<FolderSearch className="size-3.5 mr-2" />
{t("hosts.copyFileManagerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableTunnel && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=tunnel&hostId=${host.id}`,
);
toast.success(t("hosts.tunnelUrlCopied"));
}}
>
<Network className="size-3.5 mr-2" />
{t("hosts.copyTunnelUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=docker&hostId=${host.id}`,
);
toast.success(t("hosts.dockerUrlCopied"));
}}
>
<Box className="size-3.5 mr-2" />
{t("hosts.copyDockerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && metricsEnabled && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=server-stats&hostId=${host.id}`,
);
toast.success(t("hosts.serverStatsUrlCopied"));
}}
>
<Server className="size-3.5 mr-2" />
{t("hosts.copyServerStatsUrlAction")}
</DropdownMenuItem>
)}
{host.enableRdp && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=rdp&hostId=${host.id}`,
);
toast.success(t("hosts.rdpUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyRdpUrlAction")}
</DropdownMenuItem>
)}
{host.enableVnc && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=vnc&hostId=${host.id}`,
);
toast.success(t("hosts.vncUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyVncUrlAction")}
</DropdownMenuItem>
)}
{host.enableTelnet && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=telnet&hostId=${host.id}`,
);
toast.success(t("hosts.telnetUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTelnetUrlAction")}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<CopyPlus className="size-3.5 mr-2" />
{t("hosts.cloneHostAction")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
title="More options"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${host.username}@${host.ip}`,
);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Link className="size-3.5 mr-2" />
{t("hosts.copyLink")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{host.enableSsh && host.enableTerminal && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=terminal&hostId=${host.id}`,
);
toast.success(t("hosts.terminalUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTerminalUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=file-manager&hostId=${host.id}`,
);
toast.success(t("hosts.fileManagerUrlCopied"));
}}
>
<FolderSearch className="size-3.5 mr-2" />
{t("hosts.copyFileManagerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableTunnel && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=tunnel&hostId=${host.id}`,
);
toast.success(t("hosts.tunnelUrlCopied"));
}}
>
<Network className="size-3.5 mr-2" />
{t("hosts.copyTunnelUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=docker&hostId=${host.id}`,
);
toast.success(t("hosts.dockerUrlCopied"));
}}
>
<Box className="size-3.5 mr-2" />
{t("hosts.copyDockerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && metricsEnabled && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=server-stats&hostId=${host.id}`,
);
toast.success(t("hosts.serverStatsUrlCopied"));
}}
>
<Server className="size-3.5 mr-2" />
{t("hosts.copyServerStatsUrlAction")}
</DropdownMenuItem>
)}
{host.enableRdp && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=rdp&hostId=${host.id}`,
);
toast.success(t("hosts.rdpUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyRdpUrlAction")}
</DropdownMenuItem>
)}
{host.enableVnc && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=vnc&hostId=${host.id}`,
);
toast.success(t("hosts.vncUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyVncUrlAction")}
</DropdownMenuItem>
)}
{host.enableTelnet && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(
`${window.location.origin}?view=telnet&hostId=${host.id}`,
);
toast.success(t("hosts.telnetUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTelnetUrlAction")}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<CopyPlus className="size-3.5 mr-2" />
{t("hosts.cloneHostAction")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
+2 -1
View File
@@ -123,7 +123,8 @@ export function ConnectionLog({
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
onClick={hasConnectionError ? undefined : toggleExpanded}
disabled={hasConnectionError}
className="flex items-center gap-2"
>
{isExpanded ? (