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