mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
7370e8f3df
* feat: add host list sort * feat: fixed rdp truncating (taskbar invisible unless resizing window) and improved split screen system * feat: revamp conneciton persistance system to save to backend with a new connections panel to restore old connections and view current ones. Also added new user profile toggle to reopen all tabs (saves and loads from backend). Added user profile toggle for host tray click vs hover. * feat: added WOL button, added proper use of BASE_PATH, toggles/buttons in admin/user profile now are always visible regardless of sidebar width, duplicating hosts not adding jumphost/socks5, keepalive internal not mulitplying into seconds causing a keepalive error, and finally guacamole giving 1_0_0 and 1_1_0 errors * feat: add filter host button, improve alert system UI, save sidebar width to localstorage, and fix host toolbar row overflow (add host going off screen on small sidebar width) * feat: add pin rail toggle, fix command pallete toggle not working, fixed command pallete toggling when typing in a field, made file manager not uppercase, host manager custom ports not loading, guacd hosts made on >=2.2.1, fixed host tags toggling, added reorder snippet sfeature, made snippet folder clllapse and require confimration toggle work, removed file manager color toggle, and fixed macos not displaying GUI until switching to another app and coming back, and jump host servers failing. * feat: use blacksmith caching for docker compile and improve keepalive system for ssh to all match the same implementation and use the data from the host config instead of a predefined value * feat: reset host manager state if the form is left and remove file manager color logic from the removed toggle * feat: update electron version check to use new ui * feat: improve duplication system to proplery map all fields
1249 lines
41 KiB
TypeScript
1249 lines
41 KiB
TypeScript
import { toast } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Separator } from "@/components/separator";
|
|
import { Button } from "@/components/button";
|
|
import { Sheet, SheetContent } from "@/components/sheet";
|
|
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
|
import { useState, useRef, useCallback, useEffect, createRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
|
import { CommandPalette } from "@/shell/CommandPalette";
|
|
import { AppRail } from "@/sidebar/AppRail";
|
|
import type { RailView } from "@/sidebar/AppRail";
|
|
import { HostsPanel } from "@/sidebar/HostsPanel";
|
|
import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
|
|
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
|
|
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
|
|
import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
|
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
|
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,
|
|
TabType,
|
|
Host,
|
|
SplitMode,
|
|
HostFolder,
|
|
} from "@/types/ui-types";
|
|
import { PANE_COUNTS } from "@/lib/theme";
|
|
import {
|
|
getSSHHosts,
|
|
getUserInfo,
|
|
getOpenTabs,
|
|
addOpenTab,
|
|
deleteOpenTab,
|
|
patchOpenTab,
|
|
getActiveSessions,
|
|
getUserPreferences,
|
|
type UserPreferences,
|
|
type OpenTabRecord,
|
|
} from "@/main-axios";
|
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
|
import type { SSHHostWithStatus } from "@/main-axios";
|
|
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
|
|
|
|
function sshHostToHost(h: SSHHostWithStatus): Host {
|
|
return {
|
|
id: String(h.id),
|
|
name: h.name,
|
|
username: h.username,
|
|
ip: h.ip,
|
|
port: h.port,
|
|
folder: h.folder ?? "",
|
|
online: h.status === "online",
|
|
cpu: 0,
|
|
ram: 0,
|
|
lastAccess: "",
|
|
tags: h.tags ?? [],
|
|
authType: h.authType,
|
|
password: h.password,
|
|
key: typeof h.key === "string" ? h.key : undefined,
|
|
keyPassword: h.keyPassword,
|
|
keyType: h.keyType,
|
|
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
|
|
notes: h.notes,
|
|
pin: h.pin ?? false,
|
|
macAddress: h.macAddress,
|
|
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
|
enableTerminal: h.enableTerminal ?? true,
|
|
enableTunnel: h.enableTunnel ?? false,
|
|
enableFileManager: h.enableFileManager ?? false,
|
|
enableDocker: h.enableDocker ?? false,
|
|
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
|
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
|
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
|
sshPort: h.port,
|
|
rdpPort: 3389,
|
|
vncPort: 5900,
|
|
telnetPort: 23,
|
|
quickActions: (h.quickActions ?? []).map((a) => ({
|
|
name: a.name,
|
|
snippetId: String(a.snippetId),
|
|
})),
|
|
jumpHosts: (h.jumpHosts ?? []).map((j) => ({
|
|
hostId: String(j.hostId),
|
|
})),
|
|
serverTunnels: [],
|
|
defaultPath: h.defaultPath,
|
|
terminalConfig: h.terminalConfig as Host["terminalConfig"],
|
|
useSocks5: h.useSocks5,
|
|
socks5Host: h.socks5Host,
|
|
socks5Port: h.socks5Port,
|
|
socks5Username: h.socks5Username,
|
|
socks5Password: h.socks5Password,
|
|
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
|
};
|
|
}
|
|
|
|
function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
|
const root: HostFolder = { name: "root", children: [] };
|
|
const folderMap = new Map<string, HostFolder>();
|
|
const getOrCreateFolder = (path: string): HostFolder => {
|
|
if (folderMap.has(path)) return folderMap.get(path)!;
|
|
const parts = path.split(" / ");
|
|
let current = root;
|
|
let accumulated = "";
|
|
for (const part of parts) {
|
|
accumulated = accumulated ? `${accumulated} / ${part}` : part;
|
|
if (!folderMap.has(accumulated)) {
|
|
const folder: HostFolder = { name: part, children: [] };
|
|
folderMap.set(accumulated, folder);
|
|
current.children.push(folder);
|
|
}
|
|
current = folderMap.get(accumulated)!;
|
|
}
|
|
return current;
|
|
};
|
|
for (const h of hosts) {
|
|
const host = sshHostToHost(h);
|
|
if (h.folder) {
|
|
getOrCreateFolder(h.folder).children.push(host);
|
|
} else {
|
|
root.children.push(host);
|
|
}
|
|
}
|
|
return root;
|
|
}
|
|
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
|
|
|
// ─── AppShell ────────────────────────────────────────────────────────────────
|
|
|
|
export function AppShell({
|
|
username,
|
|
onLogout,
|
|
}: {
|
|
username: string;
|
|
onLogout: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [tabs, setTabs] = useState<Tab[]>([
|
|
{
|
|
id: "dashboard",
|
|
instanceId: "dashboard",
|
|
type: "dashboard",
|
|
label: t("nav.dashboard"),
|
|
openedAt: Date.now(),
|
|
},
|
|
]);
|
|
const [activeTabId, setActiveTabId] = useState("dashboard");
|
|
const [userPrefs, setUserPrefs] = useState<UserPreferences>({
|
|
reopenTabsOnLogin: false,
|
|
});
|
|
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
|
|
const [hostsLoaded, setHostsLoaded] = useState(false);
|
|
// Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this
|
|
const [tabsReady, setTabsReady] = useState(false);
|
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
const [splitMode, setSplitMode] = useState<SplitMode>(
|
|
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
|
);
|
|
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
|
() =>
|
|
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
|
Array(6).fill(null),
|
|
);
|
|
const [focusedPaneIndex, setFocusedPaneIndex] = useState<number | null>(null);
|
|
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
|
const [hostsLoading, setHostsLoading] = useState(true);
|
|
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
|
OpenTabRecord[]
|
|
>([]);
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [railView, setRailView] = useState<RailView>("hosts");
|
|
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem("termix_sidebarWidth");
|
|
return saved ? parseInt(saved, 10) : 266;
|
|
});
|
|
const [sidebarDragging, setSidebarDragging] = useState(false);
|
|
const [sidebarEditing, setSidebarEditing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
|
}, [sidebarWidth]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_splitMode", splitMode);
|
|
}, [splitMode]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
|
}, [paneTabIds]);
|
|
|
|
const isMobile = useIsMobile();
|
|
|
|
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
sidebarOpenBeforeMobile.current = sidebarOpen;
|
|
setSidebarOpen(false);
|
|
} else {
|
|
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
|
}
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
getUserInfo()
|
|
.then((info) => setIsAdmin(info.is_admin))
|
|
.catch(() => setIsAdmin(false));
|
|
}, []);
|
|
|
|
const lastShiftTime = useRef(0);
|
|
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
|
useState<boolean>(() => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
return v !== null ? v === "true" : true;
|
|
});
|
|
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
|
new Map(),
|
|
);
|
|
const [paneContentEls, setPaneContentEls] = useState<
|
|
(HTMLDivElement | null)[]
|
|
>(Array(6).fill(null));
|
|
|
|
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
|
// We always portal each tab's content into its own node, then move that node between
|
|
// the normal-view container and the pane container via vanilla DOM so React's portal
|
|
// target never changes (changing the target causes a remount).
|
|
const tabNodesRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const normalViewRef = useRef<HTMLDivElement>(null);
|
|
|
|
const getTabNode = useCallback((tabId: string, isTerminal: boolean) => {
|
|
if (!tabNodesRef.current.has(tabId)) {
|
|
const el = document.createElement("div");
|
|
el.style.position = "absolute";
|
|
el.style.inset = "0";
|
|
el.style.overflow = "hidden";
|
|
if (!isTerminal) el.classList.add("bg-background");
|
|
tabNodesRef.current.set(tabId, el);
|
|
}
|
|
return tabNodesRef.current.get(tabId)!;
|
|
}, []);
|
|
|
|
const onPaneContentRef = useCallback(
|
|
(paneIndex: number, el: HTMLDivElement | null) => {
|
|
setPaneContentEls((prev) => {
|
|
if (prev[paneIndex] === el) return prev;
|
|
const next = [...prev];
|
|
next[paneIndex] = el;
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const sidebarTitle: Record<RailView, string> = {
|
|
hosts: "Hosts",
|
|
credentials: "Credentials",
|
|
"quick-connect": "Quick Connect",
|
|
"ssh-tools": "SSH Tools",
|
|
snippets: "Snippets",
|
|
history: "History",
|
|
"split-screen": "Split Screen",
|
|
connections: t("nav.connections"),
|
|
"user-profile": "User Profile",
|
|
"admin-settings": "Admin Settings",
|
|
};
|
|
|
|
// Double-shift opens command palette
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.code === "ShiftLeft" && !e.repeat) {
|
|
const now = Date.now();
|
|
if (now - lastShiftTime.current < 300 && commandPaletteShortcutEnabled)
|
|
setCommandPaletteOpen((prev) => !prev);
|
|
lastShiftTime.current = now;
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [commandPaletteShortcutEnabled]);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
|
};
|
|
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
|
return () =>
|
|
window.removeEventListener(
|
|
"commandPaletteShortcutEnabledChanged",
|
|
handler,
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handle = () => onLogout();
|
|
window.addEventListener("termix:logout", handle);
|
|
return () => window.removeEventListener("termix:logout", handle);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const handleSessionExpired = () => onLogout();
|
|
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
|
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
if (!activeTab?.terminalRef) return;
|
|
let innerRafId: number;
|
|
const outerRafId = requestAnimationFrame(() => {
|
|
innerRafId = requestAnimationFrame(() => {
|
|
const ref = activeTab.terminalRef?.current;
|
|
ref?.fit?.();
|
|
ref?.notifyResize?.();
|
|
ref?.refresh?.();
|
|
});
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(outerRafId);
|
|
cancelAnimationFrame(innerRafId);
|
|
};
|
|
}, [activeTabId]);
|
|
|
|
useEffect(() => {
|
|
const handleDegraded = () => {
|
|
toast.loading(t("common.connectionDegraded"), {
|
|
id: "db-connection-degraded",
|
|
duration: Infinity,
|
|
dismissible: false,
|
|
action: {
|
|
label: t("common.reload"),
|
|
onClick: () => window.location.reload(),
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRestored = () => {
|
|
toast.dismiss("db-connection-degraded");
|
|
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
|
};
|
|
|
|
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
|
|
|
return () => {
|
|
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.off(
|
|
"database-connection-degraded-cleared",
|
|
handleRestored,
|
|
);
|
|
};
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
getUserPreferences()
|
|
.then((prefs) => setUserPrefs(prefs))
|
|
.catch(() => {})
|
|
.finally(() => setUserPrefsLoaded(true));
|
|
}, []);
|
|
|
|
// Load real hosts from API
|
|
const loadHosts = useCallback(async () => {
|
|
try {
|
|
const raw = await getSSHHosts();
|
|
const converted = raw.map(sshHostToHost);
|
|
setAllHosts(converted);
|
|
setRealHostTree(buildHostTree(raw));
|
|
} catch {
|
|
// Keep empty state on error
|
|
} finally {
|
|
setHostsLoading(false);
|
|
setHostsLoaded(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadHosts();
|
|
}, [loadHosts]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("termix:hosts-changed", loadHosts);
|
|
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
|
|
}, [loadHosts]);
|
|
|
|
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
|
useEffect(() => {
|
|
if (allHosts.length === 0) return;
|
|
setTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.host
|
|
? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host }
|
|
: t,
|
|
),
|
|
);
|
|
}, [allHosts]);
|
|
|
|
// Let HostManager trigger tab opens via custom event
|
|
useEffect(() => {
|
|
const handle = (e: Event) => {
|
|
const { hostId, type } = (
|
|
e as CustomEvent<{ hostId: string; type?: TabType }>
|
|
).detail;
|
|
const host = allHosts.find((h) => h.id === hostId);
|
|
if (host) connectHost(host, type);
|
|
};
|
|
window.addEventListener("termix:open-tab", handle);
|
|
return () => window.removeEventListener("termix:open-tab", handle);
|
|
}, [allHosts]);
|
|
|
|
const PERSISTENT_TAB_TYPES: TabType[] = [
|
|
"terminal",
|
|
"rdp",
|
|
"vnc",
|
|
"telnet",
|
|
"files",
|
|
"docker",
|
|
"stats",
|
|
"tunnel",
|
|
];
|
|
|
|
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
|
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
|
const tabRestoreAttemptedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!hostsLoaded || !userPrefsLoaded) return;
|
|
if (tabRestoreAttemptedRef.current) return;
|
|
tabRestoreAttemptedRef.current = true;
|
|
|
|
async function loadSavedTabs() {
|
|
try {
|
|
const [savedTabs, activeSessions] = await Promise.all([
|
|
getOpenTabs(),
|
|
getActiveSessions(),
|
|
]);
|
|
|
|
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return;
|
|
|
|
const sessionByInstanceId = new Map(
|
|
(Array.isArray(activeSessions) ? activeSessions : [])
|
|
.filter((s) => s.tabInstanceId != null)
|
|
.map((s) => [s.tabInstanceId, s]),
|
|
);
|
|
|
|
if (userPrefs.reopenTabsOnLogin) {
|
|
const hasPersistentTabs = tabs.some((t) =>
|
|
PERSISTENT_TAB_TYPES.includes(t.type),
|
|
);
|
|
if (!hasPersistentTabs) {
|
|
const restoredTabs: Tab[] = [];
|
|
for (const saved of savedTabs as OpenTabRecord[]) {
|
|
const host = saved.hostId
|
|
? allHosts.find((h) => h.id === String(saved.hostId))
|
|
: undefined;
|
|
const hostlessTypes: TabType[] = ["dashboard", "tunnel"];
|
|
if (!host && !hostlessTypes.includes(saved.tabType as TabType))
|
|
continue;
|
|
|
|
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
|
const tabId = host
|
|
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
|
|
: saved.id;
|
|
const liveSession = sessionByInstanceId.get(saved.id);
|
|
const restoredSessionId =
|
|
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
|
|
|
restoredTabs.push({
|
|
id: tabId,
|
|
instanceId: saved.id,
|
|
type: saved.tabType as TabType,
|
|
label: saved.label,
|
|
host,
|
|
openedAt: new Date(saved.createdAt).getTime(),
|
|
restoredSessionId,
|
|
terminalRef:
|
|
saved.tabType === "terminal" ? createRef() : undefined,
|
|
});
|
|
}
|
|
|
|
if (restoredTabs.length > 0) {
|
|
setTabs((prev) => {
|
|
const existingIds = new Set(prev.map((t) => t.id));
|
|
const newTabs = restoredTabs.filter(
|
|
(t) => !existingIds.has(t.id),
|
|
);
|
|
return newTabs.length > 0 ? [...prev, ...newTabs] : prev;
|
|
});
|
|
setActiveTabId(restoredTabs[0].id);
|
|
}
|
|
// Restored tabs are in the tab bar, not in background records
|
|
}
|
|
} else {
|
|
// Not restoring to tab bar — keep as background records for ConnectionsPanel
|
|
setBackgroundTabRecords(savedTabs as OpenTabRecord[]);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setTabsReady(true);
|
|
}
|
|
}
|
|
|
|
loadSavedTabs();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [hostsLoaded, userPrefsLoaded]);
|
|
|
|
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
|
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
);
|
|
const prevTabOrderRef = useRef<string>("");
|
|
useEffect(() => {
|
|
if (!tabsReady) return;
|
|
const persistable = tabs.filter((t) =>
|
|
PERSISTENT_TAB_TYPES.includes(t.type),
|
|
);
|
|
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
|
if (orderKey === prevTabOrderRef.current) return;
|
|
prevTabOrderRef.current = orderKey;
|
|
|
|
if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current);
|
|
orderSyncTimeoutRef.current = setTimeout(() => {
|
|
persistable.forEach((t, i) => {
|
|
patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {});
|
|
});
|
|
}, 500);
|
|
|
|
return () => {
|
|
if (orderSyncTimeoutRef.current)
|
|
clearTimeout(orderSyncTimeoutRef.current);
|
|
};
|
|
}, [tabs, tabsReady]);
|
|
|
|
// ─── Tab management ──────────────────────────────────────────────────────
|
|
|
|
const openTab = useCallback(function openTab(
|
|
host: Host,
|
|
type: TabType,
|
|
restore?: { instanceId: string; restoredSessionId: string | null },
|
|
) {
|
|
const tabId = `${host.name}-${type}-${Date.now()}`;
|
|
const instanceId = restore?.instanceId ?? crypto.randomUUID();
|
|
const openedAt = Date.now();
|
|
const ref = type === "terminal" ? createRef() : undefined;
|
|
if (ref) terminalRefs.current.set(tabId, ref);
|
|
|
|
let finalLabel = host.name;
|
|
setTabs((prev) => {
|
|
const same = prev.filter(
|
|
(t) =>
|
|
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
|
);
|
|
finalLabel =
|
|
same.length === 0 ? host.name : `${host.name} (${same.length + 1})`;
|
|
|
|
// Retrofit the first duplicate's label to "(1)" if needed
|
|
const next =
|
|
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
|
? prev.map((t) =>
|
|
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
|
)
|
|
: prev;
|
|
|
|
return [
|
|
...next,
|
|
{
|
|
id: tabId,
|
|
instanceId,
|
|
type,
|
|
label: finalLabel,
|
|
host,
|
|
openedAt,
|
|
terminalRef: ref,
|
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
|
},
|
|
];
|
|
});
|
|
setActiveTabId(tabId);
|
|
|
|
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
|
addOpenTab({
|
|
id: instanceId,
|
|
tabType: type,
|
|
hostId: host ? parseInt(host.id) : null,
|
|
label: finalLabel,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
}, []);
|
|
|
|
function connectHost(host: Host, preferredType?: TabType) {
|
|
const type: TabType =
|
|
preferredType ??
|
|
(host.enableSsh
|
|
? "terminal"
|
|
: host.enableRdp
|
|
? "rdp"
|
|
: host.enableVnc
|
|
? "vnc"
|
|
: host.enableTelnet
|
|
? "telnet"
|
|
: "terminal");
|
|
openTab(host, type);
|
|
}
|
|
|
|
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("host-manager:add-credential"),
|
|
),
|
|
0,
|
|
);
|
|
} else {
|
|
setSidebarOpen(true);
|
|
setRailView("hosts");
|
|
if (pendingEvent) {
|
|
setTimeout(
|
|
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (type === "user-profile" || type === "admin-settings") {
|
|
setSidebarEditing(false);
|
|
setRailView(type as RailView);
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
const id = type;
|
|
const singletonLabels: Partial<Record<TabType, string>> = {
|
|
"host-manager": t("nav.hostManager"),
|
|
docker: t("nav.docker"),
|
|
tunnel: t("nav.tunnels"),
|
|
network_graph: t("nav.networkGraph"),
|
|
};
|
|
setTabs((prev) => {
|
|
if (prev.find((t) => t.id === id)) return prev;
|
|
return [
|
|
...prev,
|
|
{
|
|
id,
|
|
instanceId: id,
|
|
type,
|
|
label: singletonLabels[type] ?? type,
|
|
openedAt: Date.now(),
|
|
},
|
|
];
|
|
});
|
|
setActiveTabId(id);
|
|
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
|
addOpenTab({
|
|
id,
|
|
tabType: type,
|
|
hostId: null,
|
|
label: singletonLabels[type] ?? type,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
|
|
|
|
function doCloseTab(id: string) {
|
|
const tabToClose = tabs.find((t) => t.id === id);
|
|
if (
|
|
tabToClose?.instanceId &&
|
|
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
|
|
) {
|
|
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
|
}
|
|
|
|
terminalRefs.current.delete(id);
|
|
if (id === activeTabId) {
|
|
const remaining = tabs.filter((t) => t.id !== id);
|
|
setActiveTabId(
|
|
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
|
);
|
|
}
|
|
setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p)));
|
|
setTabs((prev) => {
|
|
const next = prev.filter((t) => t.id !== id);
|
|
if (next.length === 0)
|
|
return [
|
|
{
|
|
id: "dashboard",
|
|
instanceId: "dashboard",
|
|
type: "dashboard",
|
|
label: t("nav.dashboard"),
|
|
openedAt: Date.now(),
|
|
},
|
|
];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function refreshTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
if (!tab) return;
|
|
if (tab.type === "terminal") {
|
|
const ref = tab.terminalRef?.current;
|
|
ref?.reconnect?.();
|
|
} else if (["rdp", "vnc", "telnet"].includes(tab.type)) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }),
|
|
);
|
|
}
|
|
}
|
|
|
|
function closeTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
|
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
|
|
toast(t("nav.confirmClose"), {
|
|
duration: 5000,
|
|
action: {
|
|
label: t("nav.close"),
|
|
onClick: () => doCloseTab(id),
|
|
},
|
|
cancel: {
|
|
label: t("nav.cancel"),
|
|
onClick: () => {},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
doCloseTab(id);
|
|
}
|
|
|
|
function splitTabQuick(tabId: string, mode: SplitMode) {
|
|
setSplitMode(mode);
|
|
setPaneTabIds(() => {
|
|
const count = PANE_COUNTS[mode];
|
|
const next: (string | null)[] = Array(6).fill(null);
|
|
next[0] = tabId;
|
|
// Fill remaining panes with other non-dashboard tabs in order
|
|
let slot = 1;
|
|
for (const tab of tabs) {
|
|
if (slot >= count) break;
|
|
if (tab.id !== tabId && tab.type !== "dashboard") {
|
|
next[slot] = tab.id;
|
|
slot++;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function addTabToSplit(tabId: string) {
|
|
setPaneTabIds((prev) => {
|
|
// Remove from any current slot first
|
|
const next = prev.map((p) => (p === tabId ? null : p));
|
|
// Find first empty slot within the current pane count
|
|
const count = PANE_COUNTS[splitMode];
|
|
for (let i = 0; i < count; i++) {
|
|
if (!next[i]) {
|
|
next[i] = tabId;
|
|
break;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function removeTabFromSplit(tabId: string) {
|
|
setPaneTabIds((prev) => prev.map((p) => (p === tabId ? null : p)));
|
|
}
|
|
|
|
function assignPane(paneIndex: number, tabId: string) {
|
|
setPaneTabIds((prev) => {
|
|
const next = prev.map((p) => (p === tabId ? null : p));
|
|
next[paneIndex] = tabId;
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// ─── Rail / sidebar ──────────────────────────────────────────────────────
|
|
|
|
function handleRailClick(view: RailView) {
|
|
if (railView === view && sidebarOpen) {
|
|
setSidebarOpen(false);
|
|
} else {
|
|
if (view !== railView) setSidebarEditing(false);
|
|
setRailView(view);
|
|
setSidebarOpen(true);
|
|
}
|
|
}
|
|
|
|
function editHostInManager(host: Host) {
|
|
setSidebarOpen(true);
|
|
setRailView("hosts");
|
|
setTimeout(() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("host-manager:edit-host", { detail: host.id }),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
const onSidebarMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setSidebarDragging(true);
|
|
const startX = e.clientX;
|
|
const startW = sidebarWidth;
|
|
function onMove(ev: MouseEvent) {
|
|
setSidebarWidth(
|
|
Math.max(160, Math.min(480, startW + ev.clientX - startX)),
|
|
);
|
|
}
|
|
function onUp() {
|
|
setSidebarDragging(false);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
}
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[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 isSplit = splitMode !== "none";
|
|
|
|
// Move each tab's stable DOM node to the right container (pane or normal-view).
|
|
// This is vanilla DOM so React's portal target never changes — changing the portal
|
|
// target causes a remount which is exactly what we're trying to avoid.
|
|
useEffect(() => {
|
|
const normalView = normalViewRef.current;
|
|
if (!normalView) return;
|
|
|
|
const tabIds = new Set(tabs.map((t) => t.id));
|
|
|
|
// Remove nodes for closed tabs
|
|
for (const [id, node] of tabNodesRef.current) {
|
|
if (!tabIds.has(id)) {
|
|
node.remove();
|
|
tabNodesRef.current.delete(id);
|
|
}
|
|
}
|
|
|
|
for (const tab of tabs) {
|
|
const isTerminal = tab.type === "terminal";
|
|
const node = getTabNode(tab.id, isTerminal);
|
|
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
|
const inPane = paneIdx !== -1;
|
|
const paneEl = inPane ? paneContentEls[paneIdx] : null;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
|
|
if (inPane && paneEl) {
|
|
if (node.parentElement !== paneEl) paneEl.appendChild(node);
|
|
node.style.visibility = "visible";
|
|
node.style.pointerEvents = "auto";
|
|
node.style.display = "";
|
|
node.style.zIndex = "";
|
|
} else {
|
|
if (node.parentElement !== normalView) normalView.appendChild(node);
|
|
if (isTerminal) {
|
|
node.style.display = "";
|
|
node.style.visibility = activeInline ? "visible" : "hidden";
|
|
node.style.pointerEvents = activeInline ? "auto" : "none";
|
|
node.style.zIndex = activeInline && !isSplit ? "1" : "0";
|
|
} else {
|
|
node.style.visibility = "";
|
|
node.style.pointerEvents = "";
|
|
node.style.zIndex = activeInline ? "2" : "";
|
|
node.style.display = activeInline ? "" : "none";
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const activeTab = tabs.find((t) => t.id === activeTabId)!;
|
|
const terminalTabs = tabs.filter((t) => t.type === "terminal");
|
|
|
|
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
|
|
const sidebarPanelContent = (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<div
|
|
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
|
>
|
|
<HostsPanel
|
|
onOpenTab={(host, type) => {
|
|
connectHost(host, type);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onEditHost={editHostInManager}
|
|
hostTree={realHostTree ?? undefined}
|
|
loading={hostsLoading}
|
|
onEditingChange={setSidebarEditing}
|
|
active={railView === "hosts"}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
|
|
>
|
|
<CredentialsPanel
|
|
onEditingChange={setSidebarEditing}
|
|
active={railView === "credentials"}
|
|
/>
|
|
</div>
|
|
|
|
{railView === "quick-connect" && (
|
|
<QuickConnectPanel
|
|
onConnect={(host, type) => {
|
|
openTab(host, type);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{railView === "ssh-tools" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SshToolsPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={activeTabId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "snippets" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SnippetsPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={activeTabId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "history" && (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} />
|
|
</div>
|
|
)}
|
|
|
|
{railView === "split-screen" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SplitScreenPanel
|
|
tabs={tabs}
|
|
splitMode={splitMode}
|
|
setSplitMode={setSplitMode}
|
|
paneTabIds={paneTabIds}
|
|
setPaneTabIds={setPaneTabIds}
|
|
onAssignPane={assignPane}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "connections" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<ConnectionsPanel
|
|
tabs={tabs}
|
|
activeTabId={activeTabId}
|
|
allHosts={allHosts}
|
|
backgroundTabRecords={backgroundTabRecords}
|
|
onSwitchToTab={(tabId) => {
|
|
setActiveTabId(tabId);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onCloseTab={closeTab}
|
|
onReopenTab={(record, restoredSessionId) => {
|
|
const host = record.hostId
|
|
? allHosts.find((h) => h.id === String(record.hostId))
|
|
: undefined;
|
|
const hostlessTypes: TabType[] = ["tunnel"];
|
|
if (!host && !hostlessTypes.includes(record.tabType as TabType))
|
|
return;
|
|
setBackgroundTabRecords((prev) =>
|
|
prev.filter((r) => r.id !== record.id),
|
|
);
|
|
if (host) {
|
|
const effectiveSessionId =
|
|
restoredSessionId ?? record.backendSessionId ?? null;
|
|
openTab(host, record.tabType as TabType, {
|
|
instanceId: record.id,
|
|
restoredSessionId: effectiveSessionId,
|
|
});
|
|
} else {
|
|
openSingletonTab(record.tabType as TabType);
|
|
}
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onForgetBackground={(recordId) => {
|
|
setBackgroundTabRecords((prev) =>
|
|
prev.filter((r) => r.id !== recordId),
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "user-profile" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<UserProfilePanel
|
|
username={username}
|
|
onLogout={onLogout}
|
|
userPrefs={userPrefs}
|
|
onPrefsChange={setUserPrefs}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "admin-settings" && isAdmin && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<AdminSettingsPanel />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Sidebar header — shared
|
|
const sidebarHeader = (
|
|
<div className="flex flex-row items-center border-b border-border h-12.5 shrink-0">
|
|
<span className="flex-1 text-base font-bold tracking-tight text-foreground px-3">
|
|
{sidebarTitle[railView]}
|
|
</span>
|
|
{!isMobile && (
|
|
<>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
|
title="Reset width"
|
|
onClick={() => setSidebarWidth(266)}
|
|
>
|
|
<Maximize2 className="size-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
|
|
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
|
<AppRail
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
splitMode={splitMode}
|
|
connectionCount={
|
|
tabs.filter((t) => PERSISTENT_TAB_TYPES.includes(t.type)).length +
|
|
backgroundTabRecords.length
|
|
}
|
|
username={username}
|
|
isAdmin={isAdmin}
|
|
profileDropdownOpen={profileDropdownOpen}
|
|
onProfileDropdownChange={setProfileDropdownOpen}
|
|
onRailClick={handleRailClick}
|
|
onOpenTab={openSingletonTab}
|
|
onLogout={onLogout}
|
|
/>
|
|
|
|
{/* Desktop: inline resizable sidebar */}
|
|
{!isMobile && (
|
|
<div
|
|
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
|
style={{
|
|
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
|
transition: sidebarDragging ? "none" : "width 0.2s",
|
|
}}
|
|
>
|
|
{sidebarHeader}
|
|
{sidebarPanelContent}
|
|
|
|
{sidebarOpen && !sidebarEditing && (
|
|
<div
|
|
onMouseDown={onSidebarMouseDown}
|
|
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: sidebar as overlay sheet */}
|
|
{isMobile && (
|
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
<SheetContent
|
|
side="left"
|
|
showCloseButton={false}
|
|
className="p-0 flex flex-col w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
|
style={{ height: "100dvh" }}
|
|
>
|
|
{sidebarHeader}
|
|
{sidebarPanelContent}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)}
|
|
|
|
{/* Main content area */}
|
|
<div
|
|
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
|
>
|
|
{!isMobile && !sidebarOpen && (
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
title="Open Sidebar"
|
|
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
|
>
|
|
<ChevronRight className="size-3.5" />
|
|
</button>
|
|
)}
|
|
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId={activeTabId}
|
|
splitMode={splitMode}
|
|
paneTabIds={paneTabIds}
|
|
focusedPaneIndex={focusedPaneIndex}
|
|
onSetActiveTab={setActiveTabId}
|
|
onCloseTab={closeTab}
|
|
onRefreshTab={refreshTab}
|
|
onReorderTabs={setTabs}
|
|
onSplitTab={splitTabQuick}
|
|
onAddToSplit={addTabToSplit}
|
|
onRemoveFromSplit={removeTabFromSplit}
|
|
/>
|
|
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
|
{!isMobile && (
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
display: isSplit ? "flex" : "none",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<SplitView
|
|
tabs={tabs}
|
|
paneTabIds={paneTabIds}
|
|
splitMode={splitMode}
|
|
focusedPaneIndex={focusedPaneIndex}
|
|
onTerminalResize={resizeAllTerminals}
|
|
onPaneContentRef={onPaneContentRef}
|
|
onPaneClick={setFocusedPaneIndex}
|
|
onAssignPane={assignPane}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
|
by the DOM-placement effect above. React portals each tab's content
|
|
into its stable per-tab node so the component is never remounted.
|
|
Hidden when split is active — pane-assigned nodes escape via vanilla DOM
|
|
appendChild to paneEl, so hiding this doesn't affect them. */}
|
|
<div
|
|
ref={normalViewRef}
|
|
className="absolute inset-0"
|
|
style={{ display: isSplit && !isMobile ? "none" : undefined }}
|
|
>
|
|
{tabs.map((tab) => {
|
|
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
|
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
|
const inPane = paneIdx !== -1;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
return createPortal(
|
|
renderTabContent(
|
|
tab,
|
|
openSingletonTab,
|
|
openTab,
|
|
closeTab,
|
|
inPane || activeInline,
|
|
),
|
|
tabNode,
|
|
tab.id,
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom nav bar — mobile only */}
|
|
<MobileBottomBar
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
splitMode={splitMode}
|
|
onRailClick={handleRailClick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<CommandPalette
|
|
isOpen={commandPaletteOpen}
|
|
setIsOpen={setCommandPaletteOpen}
|
|
hosts={allHosts}
|
|
onOpenTab={(type, label, pendingEvent) => {
|
|
if (
|
|
[
|
|
"dashboard",
|
|
"host-manager",
|
|
"user-profile",
|
|
"admin-settings",
|
|
].includes(type)
|
|
) {
|
|
openSingletonTab(type, pendingEvent);
|
|
} else if (label) {
|
|
const host = allHosts.find((h) => h.name === label);
|
|
if (host) openTab(host, type);
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|