From 9c12a84a91fe7c4350064aa04afe2d358ac25f57 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 26 May 2026 22:05:58 -0500 Subject: [PATCH] feat: continued general UI improvements --- src/backend/ssh/tunnel.ts | 19 +- src/main.tsx | 3 +- src/ui/AppShell.tsx | 248 +++++----- src/ui/dashboard/cards/NetworkGraphCard.tsx | 75 ++- src/ui/features/docker/DockerManager.tsx | 60 ++- src/ui/features/file-manager/FileManager.tsx | 3 + src/ui/features/server-stats/ServerStats.tsx | 47 +- src/ui/locales/en.json | 5 +- src/ui/shell/SplitView.tsx | 381 +++++++++++---- src/ui/sidebar/AppRail.tsx | 34 +- src/ui/sidebar/CredentialsPanel.tsx | 3 + src/ui/sidebar/HostManager.tsx | 20 +- src/ui/sidebar/HostsPanel.tsx | 4 +- src/ui/sidebar/SidebarTree.tsx | 467 ++++++++++--------- src/ui/ssh/connection-log/ConnectionLog.tsx | 3 +- 15 files changed, 867 insertions(+), 505 deletions(-) diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 5391cd41..1574bddc 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -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", diff --git a/src/main.tsx b/src/main.tsx index cfab33d2..ee4b9bc1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -145,7 +145,8 @@ function App() { }) .catch(() => {}); } - setPhase("idle-app"); + setPhase("fading-in"); + timerRef.current = setTimeout(() => setPhase("idle-app"), 450); }) .catch(() => { clearStoredAuth(); diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 30c32000..bbeddb69 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -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> = { - "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> = { + "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"} />
- +
{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} />
- {isSplit && !isMobile ? ( - - ) : ( - <> - {/* 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 ( -
- {renderTabContent( - tab, - openSingletonTab, - openTab, - closeTab, - visible, - )} -
- ); - })} - {/* Non-terminal tabs: absolutely positioned above terminals when active */} - {tabs - .filter((tab) => tab.type !== "terminal") - .map((tab) => { - const visible = tab.id === activeTabId; - return ( -
- {renderTabContent( - tab, - openSingletonTab, - openTab, - closeTab, - visible, - )} -
- ); - })} - + {/* Split view — always mounted when not mobile, hidden via CSS when inactive */} + {!isMobile && ( +
+ +
)} + + {/* Normal tab view — always mounted, hidden via CSS when split is active */} +
+ {/* 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 ( +
+ {renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + )} +
+ ); + })} + {/* Non-terminal tabs */} + {tabs + .filter((tab) => tab.type !== "terminal") + .map((tab) => { + const visible = tab.id === activeTabId; + return ( +
+ {renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + )} +
+ ); + })} +
diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 5007e69a..7804d37c 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -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) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c] ?? c, ); const tagsHtml = tags - .slice(0, 2) + .slice(0, 3) .map( (tag) => - `${esc(tag)}`, + `${esc(tag)}`, ) .join(""); + const serverIcon = ` +`; + return ( "data:image/svg+xml;utf8," + - encodeURIComponent(` - - - ${esc(name).substring(0, 18)} - ${esc(ip)} - ${tagsHtml ? `
${tagsHtml}
` : ""} -
`) + encodeURIComponent( + ` + + + + + + + + + + + + ${serverIcon} + ${esc(name)} + ${esc(ip)} + ${tagsHtml ? `
${tagsHtml}
` : ""} +
`, + ) ); } @@ -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 = (
e.preventDefault()} > {loading && ( -
+
)} {contextMenuEl} +
+
+ )}
); @@ -770,6 +791,23 @@ function DockerManagerInner({ visible={isConnecting && !isConnectionLogExpanded} message={t("docker.connecting")} /> + {hasConnectionError && !isConnectionLogExpanded && ( +
+ +

+ {t("docker.connectionFailed")} +

+ +
+ )} {currentHost && ( @@ -3004,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { username: currentHost.username, name: currentHost.name, }} + backgroundColor="var(--bg-canvas)" /> )} diff --git a/src/ui/features/server-stats/ServerStats.tsx b/src/ui/features/server-stats/ServerStats.tsx index 5b0790d0..df5a6adf 100644 --- a/src/ui/features/server-stats/ServerStats.tsx +++ b/src/ui/features/server-stats/ServerStats.tsx @@ -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({ }} >
- {!totpRequired && !isLoadingMetrics && ( + {!totpRequired && !isLoadingMetrics && !hasConnectionError && (
@@ -725,16 +737,19 @@ function ServerStatsInner({ showStatsUI && !isLoadingMetrics && !metrics && - serverStatus === "offline" && ( + serverStatus === "offline" && + !hasConnectionError && (
-
- -

- {t("serverStats.serverOffline")} -

-

- {t("serverStats.cannotFetchMetrics")} -

+
+
+ +

+ {t("serverStats.serverOffline")} +

+

+ {t("serverStats.cannotFetchMetrics")} +

+
)} @@ -767,7 +782,7 @@ function ServerStatsInner({ /> diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ab5fe3ae..8a7505a7 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/shell/SplitView.tsx b/src/ui/shell/SplitView.tsx index 03849ef3..7493ef94 100644 --- a/src/ui/shell/SplitView.tsx +++ b/src/ui/shell/SplitView.tsx @@ -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 ( -
-
+
+
+
+
); } @@ -237,12 +250,14 @@ function RowDivider({ onTouchStart: (e: React.TouchEvent) => void; }) { return ( -
-
+
+
+
+
); } @@ -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({
{tab ? ( - renderTabContent(tab, onOpenSingletonTab, onOpenTab) + ) : ( )} @@ -318,22 +350,89 @@ function Pane({ )}
); -} +}); + +// ─── 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 ( +
+ {paneIndices.map((pIdx, ci) => { + const tabId = paneTabIds[pIdx]; + const tab = + tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null; + return ( + +
+ +
+ {ci < paneIndices.length - 1 && ( + onColDivider(e, rowIdx, ci)} + onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)} + /> + )} +
+ ); + })} +
+ ); +}); // ─── 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 ( - - ); - } + 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 ( -
- {paneIndices.map((pIdx, ci) => ( - <> -
- {pane(pIdx)} -
- {ci < paneIndices.length - 1 && ( - onColDivider(e, rowIdx, ci)} - onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)} - /> - )} - - ))} -
- ); + // 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 - {splitMode === "2-way" && } + {splitMode === "2-way" && ( + + )} {splitMode === "3-way" && (
@@ -419,7 +494,13 @@ export function SplitView({ className="min-w-0 min-h-0 overflow-hidden" style={{ width: `${rowColSizes[0][0]}%` }} > - {pane(0)} +
onColDivider(e, 0, 0)} @@ -430,7 +511,13 @@ export function SplitView({ className="min-h-0 overflow-hidden" style={{ height: `${rowSizes[0]}%` }} > - {pane(1)} +
onRowDivider(e, 0)} @@ -440,7 +527,13 @@ export function SplitView({ className="min-h-0 overflow-hidden" style={{ height: `${rowSizes[1]}%` }} > - {pane(2)} +
@@ -456,14 +549,26 @@ export function SplitView({ className="min-w-0 min-h-0 overflow-hidden" style={{ width: `${rowColSizes[0][0]}%` }} > - {pane(0)} +
onColDivider(e, 0, 0)} onTouchStart={(e) => onColDividerTouch(e, 0, 0)} />
- {pane(1)} +
onRowDividerTouch(e, 0)} />
- {pane(2)} +
)} {splitMode === "4-way" && (
- + onRowDivider(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)} /> - +
)} {splitMode === "5-way" && (
- + onRowDivider(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)} /> - +
)} {splitMode === "6-way" && (
- + onRowDivider(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)} /> - +
)}
); -} +}); diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 76132421..fd1ea415 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -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: , + 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" ? ( + ) : (
diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index af6b23ae..dedc5cea 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -4328,16 +4328,7 @@ function CredentialEditorView({ {["password", "key"].map((m) => ( - ))} - {host.enableSsh && - (host.enableRdp || host.enableVnc || host.enableTelnet) && - getSshActions(host).length > 0 && ( -
+
+ {/* Connection buttons — wrap naturally to a second line */} +
+ {getSshActions(host).map(({ type, icon: Icon, label }) => ( + + ))} + {host.enableSsh && + (host.enableRdp || host.enableVnc || host.enableTelnet) && + getSshActions(host).length > 0 && ( +
+ )} + {host.enableRdp && ( + )} - {host.enableRdp && ( - - )} - {host.enableVnc && ( - - )} - {host.enableTelnet && ( - - )} - {onEditHost && ( - <> -
+ {host.enableVnc && ( + + )} + {host.enableTelnet && ( + + )} +
+ + {/* Separator + management buttons row — always fixed position */} +
+ {onEditHost && ( - - )} - {onShareHost && ( - <> -
+ )} + {onShareHost && ( - - )} - - - - - - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${host.username}@${host.ip}`, - ); - toast.success(t("hosts.copiedToClipboard")); - }} - > - - {t("hosts.copyAddress")} - - - - - {t("hosts.copyLink")} - - - {host.enableSsh && host.enableTerminal && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=terminal&hostId=${host.id}`, - ); - toast.success(t("hosts.terminalUrlCopied")); - }} - > - - {t("hosts.copyTerminalUrlAction")} - - )} - {host.enableSsh && host.enableFileManager && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=file-manager&hostId=${host.id}`, - ); - toast.success(t("hosts.fileManagerUrlCopied")); - }} - > - - {t("hosts.copyFileManagerUrlAction")} - - )} - {host.enableSsh && host.enableTunnel && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=tunnel&hostId=${host.id}`, - ); - toast.success(t("hosts.tunnelUrlCopied")); - }} - > - - {t("hosts.copyTunnelUrlAction")} - - )} - {host.enableSsh && host.enableDocker && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=docker&hostId=${host.id}`, - ); - toast.success(t("hosts.dockerUrlCopied")); - }} - > - - {t("hosts.copyDockerUrlAction")} - - )} - {host.enableSsh && metricsEnabled && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=server-stats&hostId=${host.id}`, - ); - toast.success(t("hosts.serverStatsUrlCopied")); - }} - > - - {t("hosts.copyServerStatsUrlAction")} - - )} - {host.enableRdp && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=rdp&hostId=${host.id}`, - ); - toast.success(t("hosts.rdpUrlCopied")); - }} - > - - {t("hosts.copyRdpUrlAction")} - - )} - {host.enableVnc && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=vnc&hostId=${host.id}`, - ); - toast.success(t("hosts.vncUrlCopied")); - }} - > - - {t("hosts.copyVncUrlAction")} - - )} - {host.enableTelnet && ( - { - e.stopPropagation(); - navigator.clipboard.writeText( - `${window.location.origin}?view=telnet&hostId=${host.id}`, - ); - toast.success(t("hosts.telnetUrlCopied")); - }} - > - - {t("hosts.copyTelnetUrlAction")} - - )} - - - - { - e.stopPropagation(); - onDuplicate(); - }} - > - - {t("hosts.cloneHostAction")} - - - { - e.stopPropagation(); - onDelete(); - }} - > - - {t("common.delete")} - - - + )} + + + + + + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${host.username}@${host.ip}`, + ); + toast.success(t("hosts.copiedToClipboard")); + }} + > + + {t("hosts.copyAddress")} + + + + + {t("hosts.copyLink")} + + + {host.enableSsh && host.enableTerminal && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=terminal&hostId=${host.id}`, + ); + toast.success(t("hosts.terminalUrlCopied")); + }} + > + + {t("hosts.copyTerminalUrlAction")} + + )} + {host.enableSsh && host.enableFileManager && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=file-manager&hostId=${host.id}`, + ); + toast.success(t("hosts.fileManagerUrlCopied")); + }} + > + + {t("hosts.copyFileManagerUrlAction")} + + )} + {host.enableSsh && host.enableTunnel && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=tunnel&hostId=${host.id}`, + ); + toast.success(t("hosts.tunnelUrlCopied")); + }} + > + + {t("hosts.copyTunnelUrlAction")} + + )} + {host.enableSsh && host.enableDocker && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=docker&hostId=${host.id}`, + ); + toast.success(t("hosts.dockerUrlCopied")); + }} + > + + {t("hosts.copyDockerUrlAction")} + + )} + {host.enableSsh && metricsEnabled && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=server-stats&hostId=${host.id}`, + ); + toast.success(t("hosts.serverStatsUrlCopied")); + }} + > + + {t("hosts.copyServerStatsUrlAction")} + + )} + {host.enableRdp && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=rdp&hostId=${host.id}`, + ); + toast.success(t("hosts.rdpUrlCopied")); + }} + > + + {t("hosts.copyRdpUrlAction")} + + )} + {host.enableVnc && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=vnc&hostId=${host.id}`, + ); + toast.success(t("hosts.vncUrlCopied")); + }} + > + + {t("hosts.copyVncUrlAction")} + + )} + {host.enableTelnet && ( + { + e.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}?view=telnet&hostId=${host.id}`, + ); + toast.success(t("hosts.telnetUrlCopied")); + }} + > + + {t("hosts.copyTelnetUrlAction")} + + )} + + + + { + e.stopPropagation(); + onDuplicate(); + }} + > + + {t("hosts.cloneHostAction")} + + + { + e.stopPropagation(); + onDelete(); + }} + > + + {t("common.delete")} + + + +
diff --git a/src/ui/ssh/connection-log/ConnectionLog.tsx b/src/ui/ssh/connection-log/ConnectionLog.tsx index 32a0bab0..85dcfae6 100644 --- a/src/ui/ssh/connection-log/ConnectionLog.tsx +++ b/src/ui/ssh/connection-log/ConnectionLog.tsx @@ -123,7 +123,8 @@ export function ConnectionLog({