/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-hooks/exhaustive-deps */ import type { TabHandle } from "@termix/plugin-sdk/frontend"; 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, Minimize2, PanelRight, RotateCcw, SquareArrowOutUpRight, } from "lucide-react"; import { useState, useRef, useCallback, useEffect, useLayoutEffect, createRef, lazy, Suspense, } from "react"; import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-mobile"; import { resetPermissionsCache } from "@/hooks/use-permissions"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { AppRail, type RailView } from "@/sidebar/AppRail"; import { isCoreRailView, railItemLabel, promotableIds, rightDockableIds, useRailItems, } from "@/sidebar/rail-items"; import { MultiPanelHint } from "@/sidebar/MultiPanelHint"; import { OnboardingDialog } from "@/onboarding/OnboardingDialog"; import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView"; import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts"; import { parseCustomKeybindings } from "@/api/open-tabs-api"; import { findMatchingKeybinding } from "@/lib/keybinding-match"; import type { CustomKeybinding, KeybindingActionType, } from "@/types/keybindings"; // Shell surfaces that are not needed for first paint. const CommandPalette = lazy(() => import("@/shell/CommandPalette").then((m) => ({ default: m.CommandPalette, })), ); const HostsPanel = lazy(() => import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })), ); const QuickConnectPanel = lazy(() => import("@/sidebar/QuickConnectPanel").then((m) => ({ default: m.QuickConnectPanel, })), ); const SerialPanel = lazy(() => import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })), ); const SplitScreenPanel = lazy(() => import("@/sidebar/SplitScreenPanel").then((m) => ({ default: m.SplitScreenPanel, })), ); const AlertManager = lazy(() => import("@/dashboard/panels/alerts/AlertManager").then((m) => ({ default: m.AlertManager, })), ); // Secondary rail panels — load on first open, not with the shell critical path. const SshToolsPanel = lazy(() => import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })), ); const CollabPanel = lazy(() => import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })), ); const MacrosPanel = lazy(() => import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })), ); const ShareSessionModal = lazy(() => import("@/features/session-sharing/ShareSessionModal").then((m) => ({ default: m.ShareSessionModal, })), ); /** What a session tab's handle gives the shell's share dialog. */ interface ShareTarget { hostId: number; sessionId: string; protocol: "ssh"; tabInstanceId?: string; } const SessionLogsPanel = lazy(() => import("@/sidebar/SessionLogsPanel").then((m) => ({ default: m.SessionLogsPanel, })), ); // One screen behind both rail entries. It lazy-loads the profile and admin // panels itself, so the shell no longer imports either directly. const SettingsScreen = lazy(() => import("@/settings/SettingsScreen").then((m) => ({ default: m.SettingsScreen, })), ); const CredentialsPanel = lazy(() => import("@/sidebar/CredentialsPanel").then((m) => ({ default: m.CredentialsPanel, })), ); const TermixIdPanel = lazy(() => import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })), ); const ConnectionsPanel = lazy(() => import("@/sidebar/ConnectionsPanel").then((m) => ({ default: m.ConnectionsPanel, })), ); function SidebarPanelFallback() { return (
); } import type { Tab, TabType, Host, SplitMode, HostFolder, ThemeId, FontSizeId, SerialConfig, WorkspacePayload, } from "@/types/ui-types"; import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; import { useTheme } from "@/components/theme-provider"; import { getSSHHosts, getSSHFolders, getUserInfo, getOpenTabs, addOpenTab, deleteOpenTab, patchOpenTab, createSSHHost, getActiveSessions, getUserPreferences, saveUserPreferences, dismissDonationModal, isElectron, type UserPreferences, type OpenTabRecord, } from "@/main-axios"; import { buildLayoutPayload, remapSlotIds, resolveLayoutTabTarget, snapshotData, } from "@/shell/shell-layout"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx"; import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; import { buildHostTree } from "@/sidebar/build-host-tree"; import { assignTabsToSplit, createSplitConfig, releaseSplitTabs, restoreSplitTabs, serializeSplitTabs, type PersistedSplitTab, } from "@/shell/splitTabUtils"; import { canRestoreTabType, getTabType, isPersistentTabType, isSessionTabType, type TabShellCallbacks, } from "@/shell/tab-registry"; import { getPanel, usePanels } from "@/shell/panel-registry"; import { invokeAction } from "@/shell/action-registry"; import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { notifyShellReady, notifyTabsChanged, resetShellBridge, setShellCallbacks, setShellHosts, setShellLayoutProvider, } from "@/plugin-host/shell-bridge"; import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; export { buildHostTree } from "@/sidebar/build-host-tree"; export { tabIcon, renderTabContent } from "@/shell/tabUtils"; // ─── AppShell ──────────────────────────────────────────────────────────────── export function AppShell({ username, onLogout, }: { username: string; onLogout: (options?: { manual?: boolean }) => void; }) { const { t, i18n } = useTranslation(); const { setTheme } = useTheme(); // Re-render when plugins add or remove rail items, panels or tabs. useRailItems(); const registeredPanels = usePanels(); const { settled: pluginsSettled } = usePluginStore(); const [tabs, setTabs] = useState([ { id: "dashboard", instanceId: "dashboard", type: "dashboard", label: t("nav.dashboard"), openedAt: Date.now(), }, ]); const [activeTabId, setActiveTabId] = useState("dashboard"); const [userPrefs, setUserPrefs] = useState({ 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("none"); // paneTabIds holds live tab.id values, which change on every restore, so we // can't restore it from storage directly. It starts empty and gets filled in // once by the reconciliation effect below, keyed off the stable instanceId // values saved in termix_paneInstanceIds. const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() => Array(6).fill(null), ); const paneLayoutRestoredRef = useRef(false); const splitTabsRestoredRef = useRef(false); useEffect(() => { paneTabIdsRef.current = paneTabIds; }, [paneTabIds]); const [rowSizes, setRowSizes] = useState( () => defaultSizes("none").rowSizes, ); const [rowColSizes, setRowColSizes] = useState( () => defaultSizes("none").rowColSizes, ); const changeSplitMode = useCallback((mode: SplitMode) => { setSplitMode(mode); const d = defaultSizes(mode); setRowSizes(d.rowSizes); setRowColSizes(d.rowColSizes); }, []); const [focusedPaneIndex, setFocusedPaneIndex] = useState(null); const [realHostTree, setRealHostTree] = useState(null); const [hostsLoading, setHostsLoading] = useState(true); const [allHosts, setAllHosts] = useState([]); const allHostsRef = useRef(allHosts); allHostsRef.current = allHosts; const [isAdmin, setIsAdmin] = useState(false); // The standalone desktop backend still owns system settings such as the // Tailscale API key, even though it has only one implicit user. const showAdminUI = isAdmin; const [userId, setUserId] = useState(null); const [showDonationModal, setShowDonationModal] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [backgroundTabRecords, setBackgroundTabRecords] = useState< OpenTabRecord[] >([]); // First-run onboarding. The backend hands accounts that predate this feature // an already-completed state, so only genuinely new users are interrupted. const uiPrefs = useUiPreferencesContext(); const onboardingPending = !!uiPrefs?.loaded && uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION; /** * Both onboarding entry points wait for plugins first, since plugins add * steps of their own (the AI step, feature cards). */ const loadOnboardingContext = useCallback(async () => { const { settledPromise } = await import("@/plugin-host/plugin-store"); await settledPromise(); }, []); useEffect(() => { if (!username || !onboardingPending) return; let cancelled = false; loadOnboardingContext().finally(() => { if (!cancelled) setShowOnboarding(true); }); return () => { cancelled = true; }; }, [username, onboardingPending, loadOnboardingContext]); // "Run setup again" from settings. useEffect(() => { const handler = () => { loadOnboardingContext().finally(() => setShowOnboarding(true)); }; window.addEventListener("termix:open-onboarding", handler); return () => window.removeEventListener("termix:open-onboarding", handler); }, [loadOnboardingContext]); const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState< string | undefined >(undefined); const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 291; }); const [sidebarDragging, setSidebarDragging] = useState(false); const [sidebarEditing, setSidebarEditing] = useState(false); const [settingsFullscreen, setSettingsFullscreen] = useState(false); // Right dock — a second panel column so reference panels like history can // stay visible while the left sidebar is used for something else. const [rightRailView, setRightRailView] = useState(() => { const saved = localStorage.getItem("termix_rightRailView"); return saved && rightDockableIds().includes(saved) ? (saved as RailView) : null; }); const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_rightSidebarWidth"); return saved ? parseInt(saved, 10) : 291; }); const [rightSidebarDragging, setRightSidebarDragging] = useState(false); // Remembers the last panel shown in the dock so the tab bar toggle can bring // it back instead of always falling back to the same default. const lastRightRailViewRef = useRef( localStorage.getItem("termix_lastRightRailView"), ); const [isAppFullscreen, setIsAppFullscreen] = useState( () => !!document.fullscreenElement, ); useEffect(() => { localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth)); }, [rightSidebarWidth]); useEffect(() => { if (rightRailView) { localStorage.setItem("termix_rightRailView", rightRailView); lastRightRailViewRef.current = rightRailView; localStorage.setItem("termix_lastRightRailView", rightRailView); } else { localStorage.removeItem("termix_rightRailView"); } }, [rightRailView]); useEffect(() => { if (!splitTabsRestoredRef.current) return; localStorage.setItem( "termix_splitTabs", JSON.stringify(serializeSplitTabs(tabs)), ); }, [tabs]); const isMobile = useIsMobile(); const isSettingsView = railView === "user-profile" || railView === "admin-settings"; useEffect(() => { if (!settingsFullscreen) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setSettingsFullscreen(false); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); }, [settingsFullscreen]); useEffect(() => { if (!isSettingsView) setSettingsFullscreen(false); }, [isSettingsView]); 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); setUserId(info.userId); setShowDonationModal(!!info.show_donation_modal); }) .catch(() => setIsAdmin(false)); }, []); // Plugin frontends register the tabs, panels and actions that a saved // session may need, so they load before tabs are restored. On logout every // plugin is deactivated, since the next user may see different ones. useEffect(() => { startPluginRuntime().catch(() => {}); return () => { void stopPluginRuntime(); resetShellBridge(); }; }, []); const handleDismissDonationModal = useCallback(() => { setShowDonationModal(false); dismissDonationModal().catch(() => {}); }, []); const toggleAppFullscreen = useCallback(async () => { try { if (document.fullscreenElement) { await document.exitFullscreen(); return; } if (!document.fullscreenEnabled) { toast.error("Fullscreen is not supported by this browser"); return; } await document.documentElement.requestFullscreen(); } catch { toast.error("Unable to toggle fullscreen mode"); } }, []); useEffect(() => { const handleFullscreenChange = () => { setIsAppFullscreen(!!document.fullscreenElement); }; document.addEventListener("fullscreenchange", handleFullscreenChange); return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); }, []); const lastShiftTime = useRef(0); const tabsRef = useRef(tabs); const activeTabIdRef = useRef(activeTabId); const closeActiveTabRef = useRef<() => void>(() => {}); const globalKeybindingsRef = useRef([]); const splitModeRef = useRef(splitMode); const focusedPaneIndexRef = useRef(null); const paneContentElsRef = useRef<(HTMLDivElement | null)[]>( Array(6).fill(null), ); const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null)); useEffect(() => { tabsRef.current = tabs; }, [tabs]); useEffect(() => { let cancelled = false; const load = () => { getUserPreferences() .then((prefs) => { if (cancelled) return; globalKeybindingsRef.current = parseCustomKeybindings( prefs.customKeybindings, ).filter( (binding) => binding.enabled && [ "nextTab", "previousTab", "openCommandPalette", "reconnectSession", ].includes(binding.action.type), ); }) .catch(() => {}); }; load(); window.addEventListener("customKeybindingsChanged", load); return () => { cancelled = true; window.removeEventListener("customKeybindingsChanged", load); }; }, []); useEffect(() => { const runAction = (type: KeybindingActionType) => { if (type === "openCommandPalette") { setCommandPaletteOpen(true); return; } if (type === "reconnectSession") { const tabId = activeTabIdRef.current; if (!tabId) return; const termRef = terminalRefs.current.get(tabId); (termRef?.current as TabHandle | null)?.reconnect?.(); return; } const currentTabs = tabsRef.current; if (currentTabs.length < 2) return; const index = currentTabs.findIndex( (tab) => tab.id === activeTabIdRef.current, ); const offset = type === "nextTab" ? 1 : -1; const next = (index + offset + currentTabs.length) % currentTabs.length; setActiveTabId(currentTabs[next].id); }; const handleKeyDown = (event: KeyboardEvent) => { if ( event.target instanceof Element && event.target.closest("[data-keybinding-recorder]") ) return; const binding = findMatchingKeybinding( event, globalKeybindingsRef.current, ); if (!binding) return; event.preventDefault(); event.stopPropagation(); runAction(binding.action.type); }; const handleAction = (event: Event) => runAction( (event as CustomEvent<{ type: KeybindingActionType }>).detail.type, ); window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("termix:global-keybinding", handleAction); return () => { window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("termix:global-keybinding", handleAction); }; }, []); useEffect(() => { activeTabIdRef.current = activeTabId; }, [activeTabId]); useEffect(() => { return window.electronAPI?.onCloseActiveTab?.(() => { if (dispatchCtrlW(document.activeElement)) return; closeActiveTabRef.current(); }); }, []); const skipSplitSyncRef = useRef(false); const activeTabAvailable = tabs.some((tab) => tab.id === activeTabId); useEffect(() => { const active = tabs.find((tab) => tab.id === activeTabId); const config = active?.type === "split-screen" ? active.splitConfig : null; skipSplitSyncRef.current = true; if (!config) { setSplitMode("none"); setPaneTabIds(Array(6).fill(null)); setFocusedPaneIndex(null); return; } setSplitMode(config.mode); setPaneTabIds(config.paneTabIds); setRowSizes(config.rowSizes); setRowColSizes(config.rowColSizes); setFocusedPaneIndex(0); }, [activeTabId, activeTabAvailable]); useEffect(() => { if (skipSplitSyncRef.current) { skipSplitSyncRef.current = false; return; } if (splitMode === "none") return; setTabs((prev) => { const active = prev.find((tab) => tab.id === activeTabId); if (active?.type !== "split-screen") return prev; const config = createSplitConfig(splitMode, paneTabIds, { rowSizes, rowColSizes, }); const updated = prev.map((tab) => tab.id === activeTabId ? { ...tab, splitConfig: config } : tab, ); return assignTabsToSplit(updated, activeTabId, paneTabIds); }); }, [activeTabId, paneTabIds, rowColSizes, rowSizes, splitMode]); // Panels like history and snippets act on "the terminal you're working in". // Once those panels can themselves be the active tab, activeTabId points at // the panel and the lookup misses, so remember the last terminal instead. const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId); const [shareTarget, setShareTarget] = useState(null); useEffect(() => { const active = tabs.find((t) => t.id === activeTabId); if (active && getTabType(active.type)?.commandTarget) { setLastTerminalTabId(active.id); } }, [activeTabId, tabs]); useEffect(() => { splitModeRef.current = splitMode; }, [splitMode]); useEffect(() => { focusedPaneIndexRef.current = focusedPaneIndex; }, [focusedPaneIndex]); const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = useState(() => { const v = localStorage.getItem("commandPaletteShortcutEnabled"); return v !== null ? v === "true" : true; }); const [showTabNumbers, setShowTabNumbers] = useState( () => localStorage.getItem("showTabNumbers") === "true", ); const terminalRefs = useRef>>( new Map(), ); const [paneContentEls, setPaneContentEls] = useState< (HTMLDivElement | null)[] >(Array(6).fill(null)); useEffect(() => { paneContentElsRef.current = paneContentEls; }, [paneContentEls]); // 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>(new Map()); const normalViewRef = useRef(null); // Tab id the enter animation has already played for, so a re-render while // the tab stays active (there can be several right after a switch) doesn't // replay it — only a genuine switch to a different tab should. const lastAnimatedTabIdRef = useRef(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)!; }, []); // Portal render order for tab content, kept independent of the tab bar's // visual order. Reordering tabs in the bar reorders `tabs`, and mapping // that array directly to portals reshuffles the Suspense-wrapped portal // children's sibling order in the fiber tree — React then runs its // Offscreen disconnect/reconnect pass on the ones that moved, which tears // down and rebuilds every passive effect underneath (including // react-xtermjs's terminal-creation effect), dropping the live terminal // and its WebSocket. Portal position doesn't need to track tab order at // all, so we only ever append new ids and drop closed ones here. const portalOrderRef = useRef([]); { const liveIds = new Set(tabs.map((t) => t.id)); portalOrderRef.current = portalOrderRef.current.filter((id) => liveIds.has(id), ); const known = new Set(portalOrderRef.current); for (const tab of tabs) { if (!known.has(tab.id)) portalOrderRef.current.push(tab.id); } } const tabsByPortalOrder = portalOrderRef.current .map((id) => tabs.find((t) => t.id === id)) .filter((t): t is Tab => t !== undefined); const onPaneContentRef = useCallback( (paneIndex: number, el: HTMLDivElement | null) => { setPaneContentEls((prev) => { if (prev[paneIndex] === el) return prev; const next = [...prev]; next[paneIndex] = el; return next; }); }, [], ); // Titles come from the shared rail definitions so they stay translated and // in step with the rail itself. const sidebarTitle = (view: RailView): string => railItemLabel(view, t); // Double-shift or Ctrl+K opens the command palette. Double-shift alone was // hard to discover. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isShiftKey(e) && !e.repeat) { const now = Date.now(); if (now - lastShiftTime.current < 300 && commandPaletteShortcutEnabled) setCommandPaletteOpen((prev) => !prev); lastShiftTime.current = now; } if ( (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.code === "KeyK" && commandPaletteShortcutEnabled ) { e.preventDefault(); setCommandPaletteOpen((prev) => !prev); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [commandPaletteShortcutEnabled]); // Ctrl+Shift+E toggles between the two most recent sidebar panels. const previousRailViewRef = useRef(null); const currentRailViewRef = useRef(railView); useEffect(() => { if (currentRailViewRef.current !== railView) { previousRailViewRef.current = currentRailViewRef.current; currentRailViewRef.current = railView; } }, [railView]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return; e.preventDefault(); const previous = previousRailViewRef.current; if (!sidebarOpen) { setSidebarOpen(true); return; } if (previous && previous !== railView) handleRailClick(previous); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [railView, sidebarOpen]); // Split-screen and tab navigation hotkeys // Also registered in globalShortcutHandler so xterm can invoke directly // without going through synthetic DOM events (which are unreliable). useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { if (e.code === "KeyF") { e.preventDefault(); toggleAppFullscreen(); return; } } // Ctrl+Shift+\ — toggle 2-way split (side by side) if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") { e.preventDefault(); if (splitModeRef.current !== "none") { selectSplitMode("none"); } else { selectSplitMode("2-way"); } return; } // Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom) if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Minus") { e.preventDefault(); if (splitModeRef.current !== "none") { selectSplitMode("none"); } else { selectSplitMode("3-way-horizontal"); } return; } // Alt+Arrow — navigate between panes in split mode if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { if ( e.code === "ArrowLeft" || e.code === "ArrowRight" || e.code === "ArrowUp" || e.code === "ArrowDown" ) { if (splitModeRef.current === "none") return; const count = PANE_COUNTS[splitModeRef.current]; if (count < 2) return; e.preventDefault(); const current = focusedPaneIndexRef.current ?? 0; const mode = splitModeRef.current; const dir = e.code; // Layout-aware navigation maps: [left, right, up, down] per pane index. // null means no movement in that direction. const navMap: Record = { "2-way": [ [null, 1, null, null], [0, null, null, null], ], "2-way-horizontal": [ [null, null, null, 1], [null, null, 0, null], ], "3-way": [ [null, 1, null, null], [0, null, null, 2], [0, null, 1, null], ], "3-way-horizontal": [ [null, 1, null, 2], [0, null, null, 2], [null, null, 0, null], ], "4-way": [ [null, 1, null, 2], [0, null, null, 3], [null, 3, 0, null], [2, null, 1, null], ], "5-way": [ [null, 1, null, 3], [0, 2, null, 4], [1, null, null, 4], [null, 4, 0, null], [3, null, 1, null], ], "6-way": [ [null, 1, null, 3], [0, 2, null, 4], [1, null, null, 5], [null, 4, 0, null], [3, 5, 1, null], [4, null, 2, null], ], }; const paneNav = navMap[mode]?.[current]; const dirIndex = { ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3 }[dir] ?? -1; const next = paneNav?.[dirIndex] ?? null; if (next === null) return; focusedPaneIndexRef.current = next; setFocusedPaneIndex(next); // Physically move DOM focus into the target pane's terminal const tabId = paneTabIdsRef.current[next]; if (tabId) { const termRef = terminalRefs.current.get(tabId); (termRef?.current as TabHandle | null)?.focus?.(); } return; } } // Cmd+1..9 on macOS, Alt+1..9 elsewhere — jump directly to the tab at that position const tabDigit = getTabJumpDigit(e); if (tabDigit !== null) { const currentTabs = tabsRef.current; const index = tabDigit - 1; if (index < currentTabs.length) { e.preventDefault(); setActiveTabId(currentTabs[index].id); } return; } // Ctrl+Shift+] / Ctrl+Shift+[ — cycle through open tabs (] = next, [ = previous) if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { if (e.code === "BracketRight" || e.code === "BracketLeft") { e.preventDefault(); const currentTabs = tabsRef.current; if (currentTabs.length < 2) return; const currentId = activeTabIdRef.current; const idx = currentTabs.findIndex((t) => t.id === currentId); const next = e.code === "BracketRight" ? (idx + 1) % currentTabs.length : (idx - 1 + currentTabs.length) % currentTabs.length; setActiveTabId(currentTabs[next].id); return; } } }; globalShortcutHandler.current = handleKeyDown; window.addEventListener("keydown", handleKeyDown); return () => { globalShortcutHandler.current = null; window.removeEventListener("keydown", handleKeyDown); }; }, []); 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 handler = () => { setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true"); }; window.addEventListener("showTabNumbersChanged", handler); return () => window.removeEventListener("showTabNumbersChanged", handler); }, []); useEffect(() => { const handle = (event: Event) => { const manual = event instanceof CustomEvent && (event.detail as { manual?: boolean } | undefined)?.manual === true; // Drop the cached grants so the next user never inherits them. resetPermissionsCache(); onLogout(manual ? { manual: true } : undefined); }; 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) => { const loginLanguage = consumeLoginLanguage(); setUserPrefs(prefs); if (prefs.storageMode === "cloud") { // Persist the current browser values before overwriting, so any tab can restore them if (!localStorage.getItem("termix-local-snapshot")) { const SNAPSHOT_KEYS = [ "termix-accent", "termix-font-size", "termix-ui-font", "i18nextLng", "commandAutocomplete", "commandPaletteShortcutEnabled", "showHostTags", "hostTrayOnClick", "pinAppRail", "expandAppRailOnHover", "confirmSnippetExecution", "disableUpdateCheck", "confirmTabClose", "hiddenRailTabs", ]; const snap: Record = { __theme: localStorage.getItem("termix-theme"), }; for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key); localStorage.setItem("termix-local-snapshot", JSON.stringify(snap)); } if (prefs.theme) setTheme(prefs.theme as ThemeId); if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId); if (prefs.accentColor) { localStorage.setItem("termix-accent", prefs.accentColor); applyAccentColor(prefs.accentColor); } const preferredLanguage = loginLanguage ?? prefs.language; if (preferredLanguage && preferredLanguage !== i18n.language) { void changeAppLanguage(preferredLanguage); } if (loginLanguage && loginLanguage !== prefs.language) { void saveUserPreferences({ language: loginLanguage }); } if ( prefs.commandAutocomplete !== null && prefs.commandAutocomplete !== undefined ) localStorage.setItem( "commandAutocomplete", String(prefs.commandAutocomplete), ); if ( prefs.commandPaletteEnabled !== null && prefs.commandPaletteEnabled !== undefined ) localStorage.setItem( "commandPaletteShortcutEnabled", String(prefs.commandPaletteEnabled), ); if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) { localStorage.setItem("showHostTags", String(prefs.showHostTags)); window.dispatchEvent(new CustomEvent("showHostTagsChanged")); } if ( prefs.hostTrayOnClick !== null && prefs.hostTrayOnClick !== undefined ) localStorage.setItem( "hostTrayOnClick", String(prefs.hostTrayOnClick), ); if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) { localStorage.setItem("pinAppRail", String(prefs.pinAppRail)); window.dispatchEvent(new Event("pinAppRailChanged")); } if ( prefs.expandAppRailOnHover !== null && prefs.expandAppRailOnHover !== undefined ) { localStorage.setItem( "expandAppRailOnHover", String(prefs.expandAppRailOnHover), ); window.dispatchEvent(new Event("expandAppRailOnHoverChanged")); } if ( prefs.confirmSnippetExecution !== null && prefs.confirmSnippetExecution !== undefined ) localStorage.setItem( "confirmSnippetExecution", String(prefs.confirmSnippetExecution), ); if ( prefs.disableUpdateCheck !== null && prefs.disableUpdateCheck !== undefined ) localStorage.setItem( "disableUpdateCheck", String(prefs.disableUpdateCheck), ); if ( prefs.confirmTabClose !== null && prefs.confirmTabClose !== undefined ) localStorage.setItem( "confirmTabClose", String(prefs.confirmTabClose), ); if ( prefs.hiddenRailTabs !== null && prefs.hiddenRailTabs !== undefined ) { localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs); window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged")); } } }) .catch(() => {}) .finally(() => setUserPrefsLoaded(true)); }, []); // Load real hosts from API const loadHosts = useCallback(async () => { try { const [raw, folders] = await Promise.all([ getSSHHosts(), getSSHFolders().catch(() => []), ]); const converted = raw.map(sshHostToHost); setAllHosts(converted); const folderMeta = new Map< string, { color?: string; icon?: string; credentialId?: number | null; sortOrder?: number | null; } >(); for (const f of folders) { folderMeta.set(f.name, { color: f.color ?? undefined, icon: f.icon ?? undefined, credentialId: f.credentialId ?? null, sortOrder: f.sortOrder ?? null, }); } setRealHostTree(buildHostTree(raw, folderMeta)); } catch { // Keep empty state on error } finally { setHostsLoading(false); setHostsLoaded(true); } }, []); useEffect(() => { loadHosts(); }, [loadHosts]); useEffect(() => { const onHostsChanged = () => { void loadHosts(); }; window.addEventListener("termix:hosts-changed", onHostsChanged); window.addEventListener("ssh-hosts:changed", onHostsChanged); window.addEventListener("hosts:refresh", onHostsChanged); return () => { window.removeEventListener("termix:hosts-changed", onHostsChanged); window.removeEventListener("ssh-hosts:changed", onHostsChanged); window.removeEventListener("hosts:refresh", onHostsChanged); }; }, [loadHosts]); // The Electron main process runs remote sync (pull/push hosts and // credentials with a connected Termix server) on its own timer, entirely // outside any renderer-initiated action, so nothing normally dispatches // the termix:hosts-changed / termix:credentials-changed events that // panels rely on to refetch. Without this, newly-synced hosts/credentials // only show up after a manual refresh or app restart. useEffect(() => { if (!isElectron()) return; let wasSyncing = false; const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( (status: { syncing: boolean; lastError: string | null }) => { const justFinished = wasSyncing && !status.syncing && !status.lastError; wasSyncing = status.syncing; if (justFinished) { window.dispatchEvent(new CustomEvent("termix:hosts-changed")); window.dispatchEvent(new CustomEvent("termix:credentials-changed")); } }, ); return () => unsubscribe?.(); }, []); // 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]); function buildWorkspacePayload(): WorkspacePayload { return buildLayoutPayload({ tabs, activeTabId, splitMode, paneTabIds, rowSizes, rowColSizes, sidebar: { left: { view: railView, open: sidebarOpen, width: sidebarWidth }, right: { view: rightRailView, open: rightRailView !== null, width: rightSidebarWidth, }, }, }); } async function applyLayout( payload: WorkspacePayload, name: string, ): Promise<{ skipped: string[] }> { // Tear down the current arrangement the same way an individual tab close does. for (const tab of [...tabsRef.current]) { doCloseTab(tab.id); } const slotIdToNewTabId = new Map(); const skippedTabs: string[] = []; for (const snapshot of payload.tabs) { const target = resolveLayoutTabTarget(snapshot, allHostsRef.current); if (target.kind === "skip") { skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); continue; } if (target.kind === "serial" && snapshot.serialConfig) { const newTabId = openSerialTab(snapshot.serialConfig); slotIdToNewTabId.set(snapshot.slotId, newTabId); continue; } if (target.kind === "singleton") { openSingletonTab( snapshot.type, undefined, target.host, snapshotData(snapshot), ); slotIdToNewTabId.set(snapshot.slotId, snapshot.type); continue; } if (target.kind === "host") { const newTabId = openTab(target.host, snapshot.type, { instanceId: crypto.randomUUID(), restoredSessionId: null, savedLabel: snapshot.customLabel ?? snapshot.label, initialFilePath: snapshot.initialFilePath, initialPath: snapshot.initialPath, }); slotIdToNewTabId.set(snapshot.slotId, newTabId); } } const restoredPaneIds = remapSlotIds(payload.paneTabIds, slotIdToNewTabId); let restoredSplitTabId: string | null = null; if (payload.splitMode !== "none" && restoredPaneIds.some(Boolean)) { const instanceId = crypto.randomUUID(); restoredSplitTabId = `split-${instanceId}`; const splitTab: Tab = { id: restoredSplitTabId, instanceId, type: "split-screen", label: name, openedAt: Date.now(), splitConfig: createSplitConfig( payload.splitMode, restoredPaneIds, payload, ), }; setTabs((prev) => assignTabsToSplit([...prev, splitTab], splitTab.id, restoredPaneIds), ); } const activeId = payload.activeSlotId ? (slotIdToNewTabId.get(payload.activeSlotId) ?? "dashboard") : "dashboard"; setActiveTabId(restoredSplitTabId ?? activeId); // Older payloads predate the sidebar field, so leave the docks alone then. const sidebar = payload.sidebar; if (sidebar) { if (sidebar.left.view) setRailView(sidebar.left.view as RailView); setSidebarOpen(sidebar.left.open); if (sidebar.left.width) setSidebarWidth(sidebar.left.width); const right = sidebar.right.open ? sidebar.right.view : null; setRightRailView( right && rightDockableIds().includes(right) ? (right as RailView) : null, ); if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width); } return { skipped: skippedTabs }; } // 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(() => { // Waits for plugins too, so a plugin's saved tabs come back as themselves. if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) 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) => isPersistentTabType(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; if (!canRestoreTabType(saved.tabType, host)) 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; const isCustomLabel = host && saved.label !== host.name && !/^.+ \(\d+\)$/.test(saved.label); restoredTabs.push({ id: tabId, instanceId: saved.id, type: saved.tabType as TabType, label: saved.label, customLabel: isCustomLabel ? saved.label : undefined, host, openedAt: new Date(saved.createdAt).getTime(), restoredSessionId, terminalRef: isSessionTabType(saved.tabType) ? 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(); }, [hostsLoaded, userPrefsLoaded, pluginsSettled]); // Plugins that act once the session is back (the workspaces plugin applies // a default workspace) wait for this. useEffect(() => { if (tabsReady) notifyShellReady(); }, [tabsReady]); // Restore named split tabs once their child sessions have stable live ids. The old // singleton keys are migrated once into Split #1 so existing layouts are preserved. useEffect(() => { if (!tabsReady || paneLayoutRestoredRef.current) return; paneLayoutRestoredRef.current = true; try { const savedSplitTabs = JSON.parse( localStorage.getItem("termix_splitTabs") ?? "[]", ) as PersistedSplitTab[]; if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) { setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev)); splitTabsRestoredRef.current = true; return; } const savedInstanceIds: (string | null)[] = JSON.parse( localStorage.getItem("termix_paneInstanceIds") ?? "null", ); const savedMode = localStorage.getItem("termix_splitMode") as SplitMode; if ( !Array.isArray(savedInstanceIds) || !savedMode || savedMode === "none" ) { splitTabsRestoredRef.current = true; return; } const restored = savedInstanceIds.map((instanceId) => { if (instanceId == null) return null; return tabs.find((t) => t.instanceId === instanceId)?.id ?? null; }); if (restored.some((id) => id != null)) { let sizes = defaultSizes(savedMode); try { const savedSizes = JSON.parse( localStorage.getItem("termix_paneSizes") ?? "null", ) as { rowSizes?: number[]; rowColSizes?: RowColSizes } | null; if ( Array.isArray(savedSizes?.rowSizes) && Array.isArray(savedSizes?.rowColSizes) ) { sizes = { rowSizes: savedSizes.rowSizes, rowColSizes: savedSizes.rowColSizes, }; } } catch { // silently fail } const instanceId = crypto.randomUUID(); const id = `split-${instanceId}`; const splitTab: Tab = { id, instanceId, type: "split-screen", label: "Split #1", openedAt: Date.now(), splitConfig: createSplitConfig(savedMode, restored, sizes), }; setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, restored)); setActiveTabId(id); } } catch { // silently fail } finally { splitTabsRestoredRef.current = true; localStorage.removeItem("termix_splitMode"); localStorage.removeItem("termix_paneInstanceIds"); localStorage.removeItem("termix_paneSizes"); } }, [tabsReady, tabs]); // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. const orderSyncTimeoutRef = useRef | null>( null, ); const prevTabOrderRef = useRef(""); useEffect(() => { if (!tabsReady) return; const persistable = tabs.filter((t) => isPersistentTabType(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]); // Tells plugins the arrangement changed, e.g. so the workspaces plugin can // keep its last-session snapshot current. useEffect(() => { if (tabsReady) notifyTabsChanged(); }, [ tabs, paneTabIds, splitMode, rowSizes, rowColSizes, tabsReady, railView, sidebarOpen, sidebarWidth, rightRailView, rightSidebarWidth, ]); // ─── Tab management ────────────────────────────────────────────────────── const openTab = useCallback(function openTab( host: Host, type: TabType, restore?: { instanceId: string; restoredSessionId: string | null; savedLabel?: string; initialFilePath?: string; initialPath?: string; serialConfig?: SerialConfig; joinSharedSessionId?: string | null; joinShareId?: string | null; collabRoomId?: string; }, options?: { data?: Record; label?: string; forceNewTab?: boolean; }, ) { if (!restore && !options?.forceNewTab) { const dataKey = JSON.stringify(options?.data ?? null); const existing = tabsRef.current.find( (t) => t.type === type && t.host?.id === host.id && JSON.stringify(t.data ?? null) === dataKey, ); if (existing) { setActiveTabId(existing.id); return existing.id; } } const tabId = `${host.name}-${type}-${Date.now()}`; const instanceId = restore?.instanceId ?? (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`); const openedAt = Date.now(); const ref = isSessionTabType(type) ? createRef() : undefined; if (ref) terminalRefs.current.set(tabId, ref); let finalLabel = host.name; const savedLabel = restore?.savedLabel; const initialFilePath = restore?.initialFilePath; const initialPath = restore?.initialPath; const serialConfig = restore?.serialConfig; const joinSharedSessionId = restore?.joinSharedSessionId ?? null; const joinShareId = restore?.joinShareId ?? null; // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label const isCustomLabel = savedLabel != null && savedLabel !== host.name && !/^.+ \(\d+\)$/.test(savedLabel); setTabs((prev) => { if (isCustomLabel && savedLabel) { finalLabel = savedLabel; return [ ...prev, { id: tabId, instanceId, type, label: finalLabel, customLabel: finalLabel, host, openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, joinSharedSessionId, joinShareId, initialFilePath, initialPath, serialConfig, collabRoomId: restore?.collabRoomId, }, ]; } const same = prev.filter( (t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, ); finalLabel = options?.label ? options.label : 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, joinSharedSessionId, joinShareId, initialFilePath, initialPath, serialConfig, collabRoomId: restore?.collabRoomId, data: options?.data, }, ]; }); setActiveTabId(tabId); if (isPersistentTabType(type)) { addOpenTab({ id: instanceId, tabType: type, hostId: host ? parseInt(host.id) : null, label: finalLabel, tabOrder: 0, }).catch(() => {}); } return tabId; }, []); function connectHost( host: Host, preferredType?: TabType, options?: { data?: Record; label?: string; forceNewTab?: boolean; }, ) { const type = resolveHostTabType(host, preferredType); if (!type) return; // --- tmux-monitor --- singleton tab, not a per-host tab if (type === "tmux_monitor") { openSingletonTab(type, undefined, host); return; } openTab(host, type, undefined, options); } const saveQuickConnectHost = useCallback( async (tab: Tab, host: Host) => { try { const savedHost = await createSSHHost(quickConnectHostToPayload(host)); await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); await loadHosts(); toast.success(t("hosts.hostCreated")); } catch (error) { toast.error(t("hosts.failedToSave")); throw error; } }, [loadHosts, t], ); function openSerialTab(config: SerialConfig): string { const pseudoHost: Host = { id: `serial-${Date.now()}`, name: config.path ? `${config.path} (${config.baudRate})` : `Serial (${config.baudRate})`, username: "", ip: "", port: 0, folder: "", online: false, cpu: null, ram: null, lastAccess: new Date().toISOString(), authType: "none", enableTerminal: false, enableCommandHistory: false, enableTunnel: false, enableFileManager: false, enableDocker: false, enableProxmox: false, enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: false, enableAiAssistant: false, enableSsh: false, enableRdp: false, enableVnc: false, enableTelnet: false, sshPort: 22, rdpPort: 3389, vncPort: 5900, telnetPort: 23, serverTunnels: [], quickActions: [], }; const instanceId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; return openTab(pseudoHost, "serial", { instanceId, restoredSessionId: null, serialConfig: config, }); } /** A tab type that opens a fresh tab every time (a local shell). */ function openMultiInstanceTab(type: TabType): string { const instanceId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const id = `${type}-${instanceId}`; const titleKey = getTabType(type)?.titleKey; const title = titleKey ? t(titleKey) : type; setTabs((current) => { const count = current.filter((tab) => tab.type === type).length; return [ ...current, { id, instanceId, type, label: count === 0 ? title : `${title} (${count + 1})`, openedAt: Date.now(), }, ]; }); setActiveTabId(id); return id; } // Invite awareness: rooms are discovered by polling, so a room that has // never been shown to this browser gets one toast with an Open action. useEffect(() => { const SEEN_KEY = "termix:collab-rooms-seen"; let cancelled = false; const check = async () => { try { const { listCollabRooms } = await import("@/api/collab-api"); const { rooms = [] } = await listCollabRooms(); if (cancelled) return; let seen: string[] = []; try { seen = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "[]"); } catch { seen = []; } const seenSet = new Set(seen); const fresh = rooms.filter((room) => !seenSet.has(room.id)); if (fresh.length === 0) return; localStorage.setItem( SEEN_KEY, JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]), ); // The first poll after login only records what already exists. if (seen.length === 0) return; for (const room of fresh) { if (room.ownerUserId === userId) continue; toast(t("collab.invitedTo", { name: room.name }), { action: { label: t("collab.openRoom"), onClick: () => setRailView("collab"), }, }); } } catch { /* next poll */ } }; void check(); const timer = setInterval(() => void check(), 60_000); return () => { cancelled = true; clearInterval(timer); }; }, []); const openSingletonTab = useCallback( // --- tmux-monitor --- (added optional `host` so tmux_monitor can open // with a preselected host; existing callers are unaffected) function openSingletonTab( type: TabType, pendingEvent?: string, host?: Host, data?: Record, ) { // A local shell is never a singleton: each open is its own session. if (getTabType(type)?.multiInstance) { return openMultiInstanceTab(type); } 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 if (pendingEvent === "host-manager:show-credentials") { setSidebarOpen(true); setRailView("credentials"); } 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> = { "host-manager": t("nav.hostManager"), sftp: t("nav.sftp"), tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor --- homepage: t("nav.homepage"), }; // A plugin tab names itself; promoted rail panels reuse the rail's own // label so the two stay in sync. const titleKey = getTabType(type)?.titleKey; const label = singletonLabels[type] ?? (titleKey ? t(titleKey) : railItemLabel(type, t)); setTabs((prev) => { const existing = prev.find((t) => t.id === id); if (existing) { // --- tmux-monitor --- refocusing with a host preselects it if (!host && data === undefined) return prev; return prev.map((t) => t.id === id ? { ...t, ...(host ? { host } : {}), ...(data !== undefined ? { data } : {}), } : t, ); } return [ ...prev, { id, instanceId: id, type, label, openedAt: Date.now(), ...(host ? { host } : {}), // --- tmux-monitor --- ...(data !== undefined ? { data } : {}), }, ]; }); setActiveTabId(id); if (isPersistentTabType(type)) { addOpenTab({ id, tabType: type, hostId: null, label, tabOrder: 0, }).catch(() => {}); } }, [t], ); const getTabCloseLabel = useCallback((tab: Tab) => { return tab.customLabel || tab.label || tab.host?.name || String(tab.id); }, []); const isActiveConnectionTab = useCallback((tab: Tab) => { if (!isSessionTabType(tab.type)) return false; return tab.terminalRef?.current?.isConnected?.() === true; }, []); const hasActiveConnection = useCallback(() => { return tabsRef.current.some(isActiveConnectionTab); }, [isActiveConnectionTab]); useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (!hasActiveConnection()) return; event.preventDefault(); event.returnValue = ""; return ""; }; window.addEventListener("beforeunload", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [hasActiveConnection]); function doCloseTab(id: string) { const tabToClose = tabs.find((t) => t.id === id); if (tabToClose?.terminalRef?.current?.disconnect) { tabToClose.terminalRef.current.disconnect(); } if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) { deleteOpenTab(tabToClose.instanceId).catch(() => {}); } terminalRefs.current.delete(id); if (id === activeTabId) { const remaining = tabs.filter( (tab) => tab.id !== id && !tab.parentSplitTabId, ); setActiveTabId( remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", ); } setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p))); setTabs((prev) => { const next = tabToClose?.type === "split-screen" ? releaseSplitTabs(prev, id) : prev .filter((tab) => tab.id !== id) .map((tab) => tab.type === "split-screen" && tab.splitConfig ? { ...tab, splitConfig: { ...tab.splitConfig, paneTabIds: tab.splitConfig.paneTabIds.map((paneId) => paneId === id ? null : paneId, ), }, } : tab, ); 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); const handle = tab?.terminalRef?.current; if (!handle) return; // Session tabs expose one or the other on their handle. if (handle.reconnect) handle.reconnect(); else handle.refresh?.(); } function openShareForTab(id: string) { const tab = tabs.find((t) => t.id === id); if (!tab) return; const ref = tab.terminalRef?.current as TabHandle | null | undefined; // A session tab either opens its own share dialog or hands over what // the shell's dialog needs. const target = ( ref?.getShareTarget as (() => ShareTarget | null) | undefined )?.(); if (target) { setShareTarget(target); } else if (ref?.canShare?.()) { ref.openShareModal?.(); } else { toast.error(t("sessionSharing.notReadyToShare")); } } function closeTab(id: string) { const tab = tabs.find((t) => t.id === id); const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; if (tab && confirmEnabled && isActiveConnectionTab(tab)) { const closeLabel = getTabCloseLabel(tab); const toastId = `close-tab-${id}`; toast( t("nav.confirmCloseHost", { host: closeLabel, defaultValue: `Close ${closeLabel}?`, }), { id: toastId, duration: 8000, action: { label: t("nav.close"), onClick: () => { toast.dismiss(toastId); doCloseTab(id); }, }, cancel: { label: t("nav.cancel"), onClick: () => toast.dismiss(toastId), }, }, ); return; } if (tab && isSessionTabType(tab.type) && confirmEnabled) { toast.dismiss(`close-tab-${id}`); } doCloseTab(id); } closeActiveTabRef.current = () => { const id = activeTabIdRef.current; if (id !== "dashboard") closeTab(id); }; function renameTab(tabId: string, newLabel: string) { setTabs((prev) => prev.map((t) => t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t, ), ); const tab = tabs.find((t) => t.id === tabId); if (tab?.instanceId && tab.type !== "split-screen") { patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {}); } } function splitTabQuick(tabId: string, mode: SplitMode) { if (mode === "none") return; const count = PANE_COUNTS[mode]; const paneIds: (string | null)[] = Array(6).fill(null); paneIds[0] = tabId; let slot = 1; for (const tab of tabs) { if (slot >= count) break; if ( tab.id !== tabId && tab.type !== "dashboard" && tab.type !== "split-screen" && !tab.parentSplitTabId ) { paneIds[slot++] = tab.id; } } const splitNumber = tabs.filter((tab) => tab.type === "split-screen").length + 1; const instanceId = crypto.randomUUID(); const id = `split-${instanceId}`; const sizes = defaultSizes(mode); const splitTab: Tab = { id, instanceId, type: "split-screen", label: `Split #${splitNumber}`, openedAt: Date.now(), splitConfig: createSplitConfig(mode, paneIds, sizes), }; setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, paneIds)); setActiveTabId(id); setSplitMode(mode); setPaneTabIds(paneIds); setRowSizes(sizes.rowSizes); setRowColSizes(sizes.rowColSizes); } function addTabToSplit(tabId: string) { if (splitMode === "none") { splitTabQuick(tabId, "2-way"); return; } 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 selectSplitMode(mode: SplitMode) { const active = tabs.find((tab) => tab.id === activeTabId); if (mode === "none") { if (active?.type === "split-screen") doCloseTab(active.id); return; } if (active?.type === "split-screen") { changeSplitMode(mode); return; } if (active && active.type !== "dashboard") { splitTabQuick(active.id, mode); return; } const firstSession = tabs.find( (tab) => tab.type !== "dashboard" && tab.type !== "split-screen" && !tab.parentSplitTabId, ); if (firstSession) splitTabQuick(firstSession.id, mode); } function assignPane(paneIndex: number, tabId: string) { setPaneTabIds((prev) => { const next = prev.map((p) => (p === tabId ? null : p)); next[paneIndex] = tabId || null; return next; }); } // ─── Rail / sidebar ────────────────────────────────────────────────────── // Moving a panel to the right dock rather than copying it: two live copies of // the same panel would fight over the shared editing state. function openInRightDock(view: RailView) { setRightRailView(view); if (railView === view) setSidebarOpen(false); } // Tab bar toggle: reopens whatever was last in the dock, so it behaves like a // show/hide rather than losing the user's choice each time. function toggleRightDock() { if (rightRailView) { lastRightRailViewRef.current = rightRailView; setRightRailView(null); return; } const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0]; if (fallback) setRightRailView(fallback as RailView); } function handleRailClick(view: RailView) { if (railView === view && sidebarOpen) { setSidebarOpen(false); } else { // A panel lives in one dock at a time, so the left dock reclaims it. if (rightRailView === view) setRightRailView(null); if (view !== railView) setSidebarEditing(false); if (view !== railView) setSettingsFullscreen(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], ); // Same drag, mirrored: the right dock grows as the pointer moves left. const onRightSidebarMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setRightSidebarDragging(true); const startX = e.clientX; const startW = rightSidebarWidth; function onMove(ev: MouseEvent) { setRightSidebarWidth( Math.max(160, Math.min(480, startW - (ev.clientX - startX))), ); } function onUp() { setRightSidebarDragging(false); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); } window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [rightSidebarWidth], ); // 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; ref?.fit?.(); ref?.notifyResize?.(); }); }); return id; }, [tabs]); useEffect(() => { const id = resizeAllTerminals(); return () => cancelAnimationFrame(id); }, [splitMode, sidebarWidth, sidebarOpen, rightSidebarWidth, rightRailView]); const isSplit = splitMode !== "none" && tabs.some((tab) => tab.id === activeTabId && tab.type === "split-screen"); // 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. // useLayoutEffect (not useEffect) so visibility/display are corrected before // the browser paints — otherwise the previous tab's node can flash on screen // for a frame while still visible. useLayoutEffect(() => { 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 = !!getTabType(tab.type)?.ownBackground; 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 = ""; node.style.contentVisibility = ""; } 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 ? "1" : "0"; // xterm renders to a ; visibility:hidden alone can still let // a stale composited frame flash through for a tick when switching // to/from a non-terminal tab. content-visibility:hidden fully skips // painting the subtree while keeping its layout box intact, so // fitAddon.fit() still sees correct dimensions once it's shown again. node.style.contentVisibility = activeInline ? "" : "hidden"; } else { // Plays a quick opacity fade-in on the tab that just became active. // Only play it on an actual switch into this tab -- gating on the // class alone replayed the animation on every unrelated re-render // that happened while the tab was still active (any render after // animationend had stripped the class), which looked like the // panel kept growing for up to a second after switching. if (activeInline && lastAnimatedTabIdRef.current !== tab.id) { lastAnimatedTabIdRef.current = tab.id; node.classList.remove("motion-workspace-enter"); // Force a reflow so re-adding the class restarts the animation // instead of no-oping because it was already removed this tick. void node.offsetWidth; node.classList.add("motion-workspace-enter"); node.addEventListener( "animationend", () => node.classList.remove("motion-workspace-enter"), { once: true }, ); } else if (!activeInline) { node.classList.remove("motion-workspace-enter"); if (lastAnimatedTabIdRef.current === tab.id) { lastAnimatedTabIdRef.current = null; } } node.style.visibility = ""; node.style.pointerEvents = ""; node.style.zIndex = activeInline ? "2" : ""; node.style.display = activeInline ? "" : "none"; } } } }); const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget); const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId); function reorderTopLevelTabs(reordered: Tab[]) { setTabs((prev) => [ ...reordered, ...prev.filter((tab) => tab.parentSplitTabId), ]); } // What history/snippets/ssh-tools should act on. Falls back to the remembered // terminal when the active tab isn't one, and drops it once it's closed. const targetTerminalTabId = terminalTabs.some((t) => t.id === activeTabId) ? activeTabId : terminalTabs.some((t) => t.id === lastTerminalTabId) ? lastTerminalTabId : ""; /** * Sidebar panel content, shared between the desktop sidebar, the mobile * sheet and the right dock. Takes the view rather than reading railView so * both docks can render from the same code. * * `owned` marks the dock responsible for the panels that stay mounted while * hidden (hosts, credentials, fleets). Only one dock may own them, otherwise * two live instances fight over the shared editing state. */ // The param deliberately shadows the outer railView so the body reads the // same whichever dock is rendering. /** * What plugins may ask of the shell. Rebuilt every render so it always * closes over current state, and published to the plugin runtime. */ const shellCallbacks: TabShellCallbacks = { openTab: (host, type, options) => { if (!host || getTabType(type)?.singleton) { openSingletonTab(type, undefined, host ?? undefined, options?.data); return; } openTab(host, type, undefined, options); }, openSingletonTab: (type, options) => openSingletonTab(type, undefined, undefined, options?.data), closeTab: (tabId) => closeTab(tabId), renameTab: (tabId, label) => renameTab(tabId, label), openRailView: (id) => { setRailView(id as RailView); setSidebarOpen(true); }, closeRailView: (id) => { setRailView((prev) => (prev === id ? "hosts" : prev)); setRightRailView((prev) => (prev === id ? null : prev)); }, saveQuickConnect: saveQuickConnectHost, }; // Panels close the sidebar on mobile after opening something, the same as // the hosts panel does. const panelShell: TabShellCallbacks = { ...shellCallbacks, openTab: (...args) => { shellCallbacks.openTab(...args); if (isMobile) setSidebarOpen(false); }, }; useEffect(() => { setShellCallbacks(shellCallbacks); setShellLayoutProvider({ getLayout: () => (tabsReady ? buildWorkspacePayload() : null), applyLayout: (layout, options) => applyLayout(layout as WorkspacePayload, options?.name ?? ""), }); }); useEffect(() => { setShellHosts(allHosts, hostsLoaded); }, [allHosts, hostsLoaded]); const renderSidebarPanels = (railView: RailView, owned = true) => ( }>
{owned && ( <>
{ connectHost(host, type, options); if (isMobile) setSidebarOpen(false); }} onEditHost={editHostInManager} hostTree={realHostTree ?? undefined} loading={hostsLoading} onEditingChange={setSidebarEditing} active={railView === "hosts"} />
)} {railView === "termix-id" && (
)} {railView === "serial" && ( { openSerialTab(config); if (isMobile) setSidebarOpen(false); }} /> )} {railView === "quick-connect" && ( { openTab(host, type); if (isMobile) setSidebarOpen(false); }} /> )} {railView === "ssh-tools" && (
)} {railView === "macros" && (
)} {railView === "split-screen" && (
tab.type !== "split-screen" && (!tab.parentSplitTabId || tab.parentSplitTabId === activeTabId), )} splitMode={splitMode} setSplitMode={selectSplitMode} paneTabIds={paneTabIds} setPaneTabIds={setPaneTabIds} onAssignPane={assignPane} />
)} {registeredPanels.map((panel) => { const shown = railView === panel.id; // A kept-mounted panel lives in the owning dock only, so two live // copies never fight over the same state. if (panel.keepMounted ? !owned : !shown) return null; const Panel = panel.component; return (
tab.id === targetTerminalTabId, )} active={shown} shell={panelShell} setEditing={setSidebarEditing} activeTabType={ tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined } placement={owned ? "left" : "right"} />
); })} {!isCoreRailView(railView) && !getPanel(railView) && ( )} {railView === "connections" && (
{ setActiveTabId(tabId); if (isMobile) setSidebarOpen(false); }} onCloseTab={closeTab} onReopenTab={(record, restoredSessionId) => { const host = record.hostId ? allHosts.find((h) => h.id === String(record.hostId)) : undefined; if (!host && !getTabType(record.tabType)?.hostless) 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, savedLabel: record.label, }); } else { openSingletonTab(record.tabType as TabType); } if (isMobile) setSidebarOpen(false); }} onForgetBackground={(recordId) => { setBackgroundTabRecords((prev) => prev.filter((r) => r.id !== recordId), ); }} onRenameTab={renameTab} onReorderTabs={setTabs} onJoinSharedSession={(session) => { if (!session.shareId) return; const existingHost = allHosts.find( (h) => h.id === String(session.hostId), ); const host: Host = existingHost ?? { id: String(session.hostId), name: session.hostName, username: "", ip: "", port: 0, folder: "", online: false, cpu: null, ram: null, lastAccess: new Date().toISOString(), authType: "none", enableTerminal: false, enableCommandHistory: false, enableTunnel: false, enableFileManager: false, enableDocker: false, enableProxmox: false, enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: false, enableAiAssistant: false, enableSsh: false, enableRdp: false, enableVnc: false, enableTelnet: false, sshPort: 22, rdpPort: 3389, vncPort: 5900, telnetPort: 23, serverTunnels: [], quickActions: [], }; void invokeAction("terminal.open", host, { joinSharedSessionId: session.sessionId, joinShareId: session.shareId, label: t("connections.sharedSessionLabel", { hostName: session.hostName, }), }); if (isMobile) setSidebarOpen(false); }} />
)} {railView === "collab" && (
{ const roomHost: Host = { id: `collab-${room.id}`, name: room.name, username: "", ip: "", port: 0, folder: "", online: false, cpu: null, ram: null, lastAccess: new Date().toISOString(), authType: "none", enableTerminal: false, enableCommandHistory: false, enableTunnel: false, enableFileManager: false, enableDocker: false, enableProxmox: false, enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: false, enableAiAssistant: false, enableSsh: false, enableRdp: false, enableVnc: false, enableTelnet: false, sshPort: 22, rdpPort: 3389, vncPort: 5900, telnetPort: 23, serverTunnels: [], quickActions: [], }; openTab(roomHost, "collab", { instanceId: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, restoredSessionId: null, savedLabel: room.name, collabRoomId: room.id, }); if (isMobile) setSidebarOpen(false); }} />
)} {railView === "session-logs" && (
)} {(railView === "user-profile" || (railView === "admin-settings" && showAdminUI)) && (
setUserPrefs((current) => ({ ...current, ...updates })) } remoteSyncInitialServerUrl={remoteSyncInitialServerUrl} remoteSyncReconnectRequested={remoteSyncReconnectRequested} onRemoteSyncReconnectHandled={() => setRemoteSyncReconnectRequested(false) } onEditingChange={setSidebarEditing} onOpenHostTab={(host) => { connectHost(host); if (isMobile) setSidebarOpen(false); }} />
)}
); const sidebarPanelContent = renderSidebarPanels(railView); // Sidebar header — shared const sidebarHeader = (
{sidebarTitle(railView)} {!isMobile && promotableIds().includes(railView) && ( <> )} {!isMobile && rightDockableIds().includes(railView) && ( <> )} {!isMobile && ( <> )} {isSettingsView && ( <> )}
); const sidebarHint = !isMobile && ( openSingletonTab(railView as TabType)} onOpenInRightDock={() => openInRightDock(railView)} /> ); return (
{isElectron() && ( <> { setRailView("user-profile"); if (!sidebarOpen) setSidebarOpen(true); setRemoteSyncReconnectRequested(true); }} /> { setRemoteSyncInitialServerUrl(url); setRailView("user-profile"); if (!sidebarOpen) setSidebarOpen(true); }} /> )}
{/* Skinny icon rail — desktop only, hidden on mobile */} {!settingsFullscreen && ( )} {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} {sidebarHint} {sidebarPanelContent} {sidebarOpen && !sidebarEditing && !settingsFullscreen && (
)}
)} {/* Mobile: sidebar as overlay sheet */} {isMobile && ( {sidebarHeader} {sidebarPanelContent} )} {/* Main content area */}
{!isMobile && !sidebarOpen && ( )}
{ const targetTab = tabs.find((t) => t.id === tabId); if (targetTab?.host) { void invokeAction("files.openHost", targetTab.host); } }} onOpenShare={openShareForTab} isAppFullscreen={isAppFullscreen} onToggleAppFullscreen={toggleAppFullscreen} rightDockOpen={rightRailView !== null} onToggleRightDock={isMobile ? undefined : toggleRightDock} showTabNumbers={showTabNumbers} />
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */} {!isMobile && (
changeSplitMode(splitMode)} focusedPaneIndex={focusedPaneIndex} onTerminalResize={resizeAllTerminals} onPaneContentRef={onPaneContentRef} onPaneClick={setFocusedPaneIndex} onAssignPane={assignPane} />
)} {/* 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. When split is active, shown on top only if the active tab is not in a pane. */}
{tabsByPortalOrder.map((tab) => { const tabNode = getTabNode( tab.id, !!getTabType(tab.type)?.ownBackground, ); const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; const inPane = paneIdx !== -1; const activeInline = !inPane && tab.id === activeTabId; const isFocusedPane = inPane ? paneIdx === (focusedPaneIndex ?? 0) : activeInline; return createPortal( renderTabContent(tab, { shell: shellCallbacks, isVisible: inPane || activeInline, isFocusedPane, panelProps: { terminalTabs, targetTerminalTabId, storageMode: userPrefs.storageMode === "cloud" ? "cloud" : "local", }, }), tabNode, tab.id, ); })}
{/* Bottom nav bar — mobile only */}
{/* Right dock — desktop only, holds a second reference panel */} {!isMobile && rightRailView && !settingsFullscreen && (
{sidebarTitle(rightRailView)}
{renderSidebarPanels(rightRailView, false)}
)}
{shareTarget && ( setShareTarget(null)} hostId={shareTarget.hostId} sessionId={shareTarget.sessionId} protocol={shareTarget.protocol} tabInstanceId={shareTarget.tabInstanceId} /> )} {commandPaletteOpen && ( handleRailClick(view as RailView)} onOpenTab={(type, label, pendingEvent) => { if ( [ "dashboard", "host-manager", "user-profile", "admin-settings", ].includes(type) ) { openSingletonTab(type, pendingEvent); } else if (getTabType(type)?.multiInstance) { openMultiInstanceTab(type); } else if (type === "tmux_monitor") { // --- tmux-monitor --- singleton tab, optionally preselecting a host openSingletonTab( type, undefined, label ? allHosts.find((h) => h.name === label) : undefined, ); } else if (label) { const host = allHosts.find((h) => h.name === label); if (host) openTab(host, type); } }} /> )} setShowOnboarding(false)} /> ); }