Files
Termix/src/ui/AppShell.tsx
T

3324 lines
113 KiB
TypeScript

/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
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 { useAiAvailability } from "@/hooks/use-ai-availability";
import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import {
railItemLabel,
PROMOTABLE_IDS,
RIGHT_DOCKABLE_IDS,
} 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 SnippetsPanel = lazy(() =>
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
);
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
const FleetsPanel = lazy(() =>
import("@/sidebar/FleetsPanel").then((m) => ({ default: m.FleetsPanel })),
);
const WorkspacesPanel = lazy(() =>
import("@/sidebar/WorkspacesPanel").then((m) => ({
default: m.WorkspacesPanel,
})),
);
const AutomationsPanel = lazy(() =>
import("@/sidebar/AutomationsPanel").then((m) => ({
default: m.AutomationsPanel,
})),
);
const AiPanel = lazy(() =>
import("@/features/ai/AiPanel").then((m) => ({
default: m.AiPanel,
})),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
);
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
})),
);
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const PortForwardingPanel = lazy(() =>
import("@/sidebar/PortForwardingPanel").then((m) => ({
default: m.PortForwardingPanel,
})),
);
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 (
<div className="flex flex-1 items-center justify-center p-6">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
import type {
Tab,
TabType,
Host,
SplitMode,
HostFolder,
ThemeId,
FontSizeId,
SerialConfig,
Workspace,
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 {
listWorkspaces,
applyWorkspaceServer,
saveLastSessionWorkspace,
} from "@/api/workspaces-api";
import {
buildWorkspacePayload as buildWorkspacePayloadUtil,
remapSlotIds,
resolveWorkspaceTabTarget,
} from "@/shell/workspaceUtils";
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 { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
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";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ────────────────────────────────────────────────────────────────
/**
* Tab types whose open/close is mirrored to the backend's open-tabs record, so
* they survive a reload or reopen on another device.
*
* "web-endpoint" is deliberately ABSENT. The backend closes an idle tunnel
* after ten minutes and re-binds a fresh kernel-assigned port on the next
* open, so a restored web-endpoint tab could never hold a valid URL -- and the
* endpoint may have been edited or deleted meanwhile besides. Restoring one
* would mean re-opening the tunnel on restore, which is a feature, not
* symmetry. A web-endpoint tab is session-only and simply closes on reload,
* the same as "local-terminal" already does.
*/
export const PERSISTENT_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"files",
"docker",
"host-metrics",
"tunnel",
];
export function AppShell({
username,
onLogout,
}: {
username: string;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
const { globallyEnabled: aiGloballyEnabled, loaded: aiStatusLoaded } =
useAiAvailability();
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>("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<number[]>(
() => defaultSizes("none").rowSizes,
);
const [rowColSizes, setRowColSizes] = useState<RowColSizes>(
() => defaultSizes("none").rowColSizes,
);
const changeSplitMode = useCallback((mode: SplitMode) => {
setSplitMode(mode);
const d = defaultSizes(mode);
setRowSizes(d.rowSizes);
setRowColSizes(d.rowColSizes);
}, []);
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);
// 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<string | null>(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [onboardingAiEnabled, setOnboardingAiEnabled] = 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 resolve the same context first: whether an
* admin has enabled the AI assistant. The AI step is skipped entirely when
* it is off, so the answer has to be in before the dialog opens.
*/
const loadOnboardingContext = useCallback(async () => {
try {
const { getAiStatus } = await import("@/api/ai-api");
const aiStatus = await getAiStatus();
setOnboardingAiEnabled(aiStatus.globallyEnabled);
} catch {
setOnboardingAiEnabled(false);
}
}, []);
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<RailView>("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<RailView | null>(() => {
const saved = localStorage.getItem("termix_rightRailView");
return saved && RIGHT_DOCKABLE_IDS.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<string | null>(
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));
}, []);
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<CustomKeybinding[]>([]);
const splitModeRef = useRef(splitMode);
const focusedPaneIndexRef = useRef<number | null>(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
import("@/features/terminal/Terminal").TerminalHandle | 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);
useEffect(() => {
const active = tabs.find((t) => t.id === activeTabId);
if (active?.type === "terminal") setLastTerminalTabId(active.id);
}, [activeTabId, tabs]);
useEffect(() => {
splitModeRef.current = splitMode;
}, [splitMode]);
useEffect(() => {
focusedPaneIndexRef.current = focusedPaneIndex;
}, [focusedPaneIndex]);
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
useState<boolean>(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
() => localStorage.getItem("showTabNumbers") === "true",
);
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
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<Map<string, HTMLDivElement>>(new Map());
const normalViewRef = useRef<HTMLDivElement>(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<string | null>(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<string[]>([]);
{
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<RailView | null>(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<string, (number | null)[][]> = {
"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
import("@/features/terminal/Terminal").TerminalHandle | 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;
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",
"defaultSnippetFoldersCollapsed",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
"hiddenRailTabs",
];
const snap: Record<string, string | null> = {
__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.foldersCollapsed !== null &&
prefs.foldersCollapsed !== undefined
)
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
String(prefs.foldersCollapsed),
);
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 buildWorkspacePayloadUtil({
tabs,
activeTabId,
splitMode,
paneTabIds,
rowSizes,
rowColSizes,
sidebar: {
left: { view: railView, open: sidebarOpen, width: sidebarWidth },
right: {
view: rightRailView,
open: rightRailView !== null,
width: rightSidebarWidth,
},
},
});
}
async function applyWorkspace(workspace: Workspace) {
// 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<string, string>();
const skippedTabs: string[] = [];
for (const snapshot of workspace.payload.tabs) {
const target = resolveWorkspaceTabTarget(snapshot, allHosts);
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,
snapshot.fleetId,
);
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(
workspace.payload.paneTabIds,
slotIdToNewTabId,
);
let restoredSplitTabId: string | null = null;
if (
workspace.payload.splitMode !== "none" &&
restoredPaneIds.some(Boolean)
) {
const instanceId = crypto.randomUUID();
restoredSplitTabId = `split-${instanceId}`;
const splitTab: Tab = {
id: restoredSplitTabId,
instanceId,
type: "split-screen",
label: workspace.name,
openedAt: Date.now(),
splitConfig: createSplitConfig(
workspace.payload.splitMode,
restoredPaneIds,
workspace.payload,
),
};
setTabs((prev) =>
assignTabsToSplit([...prev, splitTab], splitTab.id, restoredPaneIds),
);
}
const activeId = workspace.payload.activeSlotId
? (slotIdToNewTabId.get(workspace.payload.activeSlotId) ?? "dashboard")
: "dashboard";
setActiveTabId(restoredSplitTabId ?? activeId);
// Older payloads predate the sidebar field, so leave the docks alone then.
const sidebar = workspace.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 && RIGHT_DOCKABLE_IDS.includes(right)
? (right as RailView)
: null,
);
if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width);
}
if (skippedTabs.length > 0) {
toast.warning(
t("newUi.sidebar.workspaces.tabsSkipped", {
count: skippedTabs.length,
names: skippedTabs.join(", "),
}),
);
}
applyWorkspaceServer(workspace.id).catch(() => {});
}
// 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;
if (host) {
if (saved.tabType === "terminal" && !host.enableSsh) continue;
if (saved.tabType === "rdp" && !host.enableRdp) continue;
if (saved.tabType === "vnc" && !host.enableVnc) continue;
if (saved.tabType === "telnet" && !host.enableTelnet) 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: SESSION_TAB_TYPES.includes(
saved.tabType as 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]);
// If reopenTabsOnLogin didn't already restore anything (off, or on but
// nothing to restore), auto-apply the user's default workspace if they set
// one. Runs once, after the open-tabs restore above has had its chance —
// that path wins when both would otherwise fire, since it's more granular
// and live-session-aware than a workspace snapshot.
const defaultWorkspaceAttemptedRef = useRef(false);
useEffect(() => {
if (!tabsReady || defaultWorkspaceAttemptedRef.current) return;
defaultWorkspaceAttemptedRef.current = true;
const hasPersistentTabs = tabs.some((t) =>
PERSISTENT_TAB_TYPES.includes(t.type),
);
if (userPrefs.reopenTabsOnLogin && hasPersistentTabs) return;
listWorkspaces()
.then((workspaces) => {
const defaultWorkspace = workspaces.find(
(w) => w.kind === "manual" && w.isDefault,
);
if (defaultWorkspace) {
applyWorkspace(defaultWorkspace);
}
})
.catch(() => {});
}, [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<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]);
// Debounced "Last Session" auto-save: keeps an implicit workspace snapshot
// current so the arrangement can always be recovered, even if the user never
// manually saves one. Never auto-applied on login — see the default-workspace
// effect above, which only considers kind === "manual" rows.
const lastSessionSaveTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
useEffect(() => {
if (!tabsReady) return;
if (lastSessionSaveTimeoutRef.current)
clearTimeout(lastSessionSaveTimeoutRef.current);
lastSessionSaveTimeoutRef.current = setTimeout(() => {
saveLastSessionWorkspace(buildWorkspacePayload()).catch(() => {});
}, 2000);
return () => {
if (lastSessionSaveTimeoutRef.current)
clearTimeout(lastSessionSaveTimeoutRef.current);
};
}, [
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?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) {
if (!restore && !options?.forceNewTab) {
const existing = tabsRef.current.find(
(t) =>
t.type === type &&
t.host?.id === host.id &&
t.endpointId === options?.endpointId,
);
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 = SESSION_TAB_TYPES.includes(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,
endpointId: options?.endpointId,
},
];
});
setActiveTabId(tabId);
if (PERSISTENT_TAB_TYPES.includes(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?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) {
const type = resolveHostTabType(host, preferredType);
// --- 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,
});
}
function openLocalTerminalTab(): string {
const instanceId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const id = `local-terminal-${instanceId}`;
setTabs((current) => {
const count = current.filter(
(tab) => tab.type === "local-terminal",
).length;
return [
...current,
{
id,
instanceId,
type: "local-terminal",
label:
count === 0
? t("nav.localTerminal")
: `${t("nav.localTerminal")} (${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,
fleetId?: number,
) {
// Local terminals are never singletons, each one is its own shell.
if (type === "local-terminal") {
return openLocalTerminalTab();
}
// The admin kill switch removes the assistant for everyone, so it can
// never be promoted into the tab bar while it is off.
if (type === "ai" && !aiGloballyEnabled) return;
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<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
docker: t("nav.docker"),
tunnel: t("nav.tunnels"),
sftp: t("nav.sftp"),
network_graph: t("nav.networkGraph"),
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
homepage: t("nav.homepage"),
"fleet-inventory": t("nav.fleets"),
};
// Promoted rail panels reuse the rail's own label so the two stay in sync.
const label = singletonLabels[type] ?? 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 && fleetId === undefined) return prev;
return prev.map((t) =>
t.id === id
? {
...t,
...(host ? { host } : {}),
...(fleetId !== undefined ? { fleetId } : {}),
}
: t,
);
}
return [
...prev,
{
id,
instanceId: id,
type,
label,
openedAt: Date.now(),
...(host ? { host } : {}), // --- tmux-monitor ---
...(fleetId !== undefined ? { fleetId } : {}),
},
];
});
setActiveTabId(id);
if (PERSISTENT_TAB_TYPES.includes(type)) {
addOpenTab({
id,
tabType: type,
hostId: null,
label,
tabOrder: 0,
}).catch(() => {});
}
},
[t, aiGloballyEnabled],
);
const SESSION_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"serial",
];
const ACTIVE_CLOSE_CONFIRM_TYPES: TabType[] = SESSION_TAB_TYPES;
const getTabCloseLabel = useCallback((tab: Tab) => {
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
}, []);
const isActiveConnectionTab = useCallback((tab: Tab) => {
if (!ACTIVE_CLOSE_CONFIRM_TYPES.includes(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 &&
PERSISTENT_TAB_TYPES.includes(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);
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 openShareForTab(id: string) {
const tab = tabs.find((t) => t.id === id);
if (!tab) return;
const ref = tab.terminalRef?.current;
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 && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
toast.dismiss(`close-tab-${id}`);
}
doCloseTab(id);
}
// An admin can turn the assistant off while tabs are already open, and a
// saved workspace or restored session can bring one back. Either way the
// leftover tab and panel go away as soon as the status says it is off.
useEffect(() => {
if (!aiStatusLoaded || aiGloballyEnabled) return;
if (tabs.some((tab) => tab.type === "ai")) doCloseTab("ai");
setRailView((prev) => (prev === "ai" ? "hosts" : prev));
setRightRailView((prev) => (prev === "ai" ? null : prev));
}, [aiStatusLoaded, aiGloballyEnabled, tabs]);
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 ?? RIGHT_DOCKABLE_IDS[0];
if (fallback) setRightRailView(fallback as RailView);
}
function handleRailClick(view: RailView) {
if (view === "sftp") {
openSingletonTab("sftp");
if (isMobile) setSidebarOpen(false);
return;
}
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 =
tab.type === "terminal" || tab.type === "local-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 = "";
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 <canvas>; 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) => t.type === "terminal");
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.
const renderSidebarPanels = (railView: RailView, owned = true) => (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{owned && (
<>
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
>
<HostsPanel
onOpenTab={(host, type, options) => {
connectHost(host, type, options);
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 === "port-forwarding" && (
<div className="flex flex-col flex-1 min-h-0">
<PortForwardingPanel />
</div>
)}
{railView === "termix-id" && (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel />
</div>
)}
{railView === "serial" && (
<SerialPanel
onConnect={(config) => {
openSerialTab(config);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{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={targetTerminalTabId}
/>
</div>
)}
{railView === "snippets" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SnippetsPanel
terminalTabs={terminalTabs}
activeTabId={targetTerminalTabId}
storageMode={
userPrefs.storageMode === "cloud" ? "cloud" : "local"
}
/>
</div>
)}
{railView === "macros" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<MacrosPanel
terminalTabs={terminalTabs}
activeTabId={targetTerminalTabId}
storageMode={
userPrefs.storageMode === "cloud" ? "cloud" : "local"
}
/>
</div>
)}
{owned && (
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "fleets" ? "" : "hidden"}`}
>
<FleetsPanel
active={railView === "fleets"}
onOpenFleetInventory={(fleetId) =>
openSingletonTab(
"fleet-inventory",
undefined,
undefined,
fleetId,
)
}
/>
</div>
)}
{railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel
terminalTabs={terminalTabs}
activeTabId={targetTerminalTabId}
/>
</div>
)}
{railView === "split-screen" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SplitScreenPanel
tabs={tabs.filter(
(tab) =>
tab.type !== "split-screen" &&
(!tab.parentSplitTabId ||
tab.parentSplitTabId === activeTabId),
)}
splitMode={splitMode}
setSplitMode={selectSplitMode}
paneTabIds={paneTabIds}
setPaneTabIds={setPaneTabIds}
onAssignPane={assignPane}
/>
</div>
)}
{railView === "workspaces" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<WorkspacesPanel
active={railView === "workspaces"}
currentPayload={buildWorkspacePayload}
onApplyWorkspace={applyWorkspace}
/>
</div>
)}
{railView === "automations" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<AutomationsPanel
active={railView === "automations"}
onEditingChange={setSidebarEditing}
/>
</div>
)}
{railView === "ai" && (
<div className="flex flex-col flex-1 min-h-0">
<AiPanel
activeTab={
tabs.find((tab) => tab.id === activeTabId)?.type ?? null
}
/>
</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,
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: [],
};
const instanceId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
openTab(host, "terminal", {
instanceId,
restoredSessionId: null,
joinSharedSessionId: session.sessionId,
joinShareId: session.shareId,
savedLabel: t("connections.sharedSessionLabel", {
hostName: session.hostName,
}),
});
if (isMobile) setSidebarOpen(false);
}}
/>
</div>
)}
{railView === "collab" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<CollabPanel
onOpenRoom={(room) => {
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);
}}
/>
</div>
)}
{railView === "session-logs" && (
<div className="relative flex-1 min-h-0 flex flex-col">
<SessionLogsPanel />
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={(updates) =>
setUserPrefs((current) => ({ ...current, ...updates }))
}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => {
connectHost(host);
if (isMobile) setSidebarOpen(false);
}}
/>
</div>
)}
{railView === "alerts" && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<AlertsPanel />
</div>
)}
</div>
</Suspense>
);
const sidebarPanelContent = renderSidebarPanels(railView);
// 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 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
{sidebarTitle(railView)}
</span>
{!isMobile && PROMOTABLE_IDS.includes(railView) && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
title={t("nav.openAsTab")}
aria-label={t("nav.openAsTab")}
onClick={() => openSingletonTab(railView as TabType)}
>
<SquareArrowOutUpRight className="size-3.5" />
</Button>
</>
)}
{!isMobile && RIGHT_DOCKABLE_IDS.includes(railView) && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
title={t("nav.openInRightDock")}
aria-label={t("nav.openInRightDock")}
onClick={() => openInRightDock(railView)}
>
<PanelRight className="size-3.5" />
</Button>
</>
)}
{!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(291)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
)}
{isSettingsView && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={
settingsFullscreen
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
: t("newUi.sidebar.userProfile.openFullscreenSettings")
}
aria-label={
settingsFullscreen
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
: t("newUi.sidebar.userProfile.openFullscreenSettings")
}
onClick={() => setSettingsFullscreen((value) => !value)}
>
{settingsFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</Button>
</>
)}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title="Collapse sidebar"
onClick={() => {
setSettingsFullscreen(false);
setSidebarOpen(false);
}}
>
<ChevronLeft className="size-4" />
</Button>
</div>
);
const sidebarHint = !isMobile && (
<MultiPanelHint
canPromote={PROMOTABLE_IDS.includes(railView)}
canRightDock={RIGHT_DOCKABLE_IDS.includes(railView)}
onOpenAsTab={() => openSingletonTab(railView as TabType)}
onOpenInRightDock={() => openInRightDock(railView)}
/>
);
return (
<ServerStatusProvider isAuthenticated={!!username}>
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
{isElectron() && (
<>
<RemoteSyncBanner
onReconnect={() => {
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
setRemoteSyncReconnectRequested(true);
}}
/>
<MigrationNoticeDialog
onOpenRemoteSync={(url) => {
setRemoteSyncInitialServerUrl(url);
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
}}
/>
</>
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
{!settingsFullscreen && (
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
username={username}
isAdmin={showAdminUI}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onOpenInRightDock={openInRightDock}
onLogout={onLogout}
/>
)}
{/* Desktop: inline resizable sidebar */}
{!isMobile && (
<div
className={`${settingsFullscreen ? "fixed inset-0 z-50" : "relative"} flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
style={{
width: settingsFullscreen
? "100vw"
: sidebarOpen
? sidebarEditing
? 560
: sidebarWidth
: 0,
transition: sidebarDragging ? "none" : "width 0.2s",
}}
>
{sidebarHeader}
{sidebarHint}
{sidebarPanelContent}
{sidebarOpen && !sidebarEditing && !settingsFullscreen && (
<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 min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${settingsFullscreen ? "w-screen" : "w-[min(85vw,360px)]"}`}
style={{ height: "100dvh" }}
>
{sidebarHeader}
{sidebarPanelContent}
</SheetContent>
</Sheet>
)}
{/* Main content area */}
<div
inert={settingsFullscreen ? true : undefined}
aria-hidden={settingsFullscreen || undefined}
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] 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={topLevelTabs}
activeTabId={activeTabId}
splitMode={splitMode}
paneTabIds={paneTabIds}
focusedPaneIndex={focusedPaneIndex}
onSetActiveTab={setActiveTabId}
onCloseTab={closeTab}
onRefreshTab={refreshTab}
onReorderTabs={reorderTopLevelTabs}
onSplitTab={splitTabQuick}
onAddToSplit={addTabToSplit}
onRemoveFromSplit={removeTabFromSplit}
onRenameTab={renameTab}
onOpenFileManager={(tabId) => {
const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) openTab(targetTab.host, "files");
}}
onOpenShare={openShareForTab}
isAppFullscreen={isAppFullscreen}
onToggleAppFullscreen={toggleAppFullscreen}
rightDockOpen={rightRailView !== null}
onToggleRightDock={isMobile ? undefined : toggleRightDock}
showTabNumbers={showTabNumbers}
/>
<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="motion-workspace-layout absolute inset-0"
style={{
display: isSplit ? "flex" : "none",
flexDirection: "column",
}}
>
<SplitView
tabs={tabs}
paneTabIds={paneTabIds}
splitMode={splitMode}
rowSizes={rowSizes}
rowColSizes={rowColSizes}
onRowSizesChange={setRowSizes}
onRowColSizesChange={setRowColSizes}
onReset={() => changeSplitMode(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.
When split is active, shown on top only if the active tab is not in a pane. */}
<div
ref={normalViewRef}
className="absolute inset-0"
style={{
display: isSplit && !isMobile ? "none" : undefined,
}}
>
{tabsByPortalOrder.map((tab) => {
const tabNode = getTabNode(
tab.id,
tab.type === "terminal" || tab.type === "local-terminal",
);
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,
openSingletonTab,
openTab,
closeTab,
inPane || activeInline,
(host, filePath) =>
openTab(host, "files", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialFilePath: filePath,
}),
(host, path) =>
openTab(host, "files", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialPath: path,
}),
(host, path) =>
openTab(host, "terminal", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialFilePath: path,
}),
renameTab,
saveQuickConnectHost,
isFocusedPane,
{
terminalTabs,
targetTerminalTabId,
storageMode:
userPrefs.storageMode === "cloud"
? "cloud"
: "local",
},
),
tabNode,
tab.id,
);
})}
</div>
</div>
</div>
{/* Bottom nav bar — mobile only */}
<MobileBottomBar
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
onRailClick={handleRailClick}
/>
</div>
{/* Right dock — desktop only, holds a second reference panel */}
{!isMobile && rightRailView && !settingsFullscreen && (
<div
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden border-l transition-colors ${rightSidebarDragging ? "border-accent-brand/60" : "border-border"}`}
style={{
width: rightSidebarWidth,
transition: rightSidebarDragging ? "none" : "width 0.2s",
}}
>
<div className="flex flex-row items-center border-b border-border h-12.5 shrink-0">
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
{sidebarTitle(rightRailView)}
</span>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={t("nav.closeRightDock")}
aria-label={t("nav.closeRightDock")}
onClick={() => setRightRailView(null)}
>
<ChevronRight className="size-4" />
</Button>
</div>
{renderSidebarPanels(rightRailView, false)}
<div
onMouseDown={onRightSidebarMouseDown}
className={`absolute left-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${rightSidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
/>
</div>
)}
</div>
</div>
{commandPaletteOpen && (
<Suspense fallback={null}>
<CommandPalette
isOpen={commandPaletteOpen}
setIsOpen={setCommandPaletteOpen}
hosts={allHosts}
terminalTabs={terminalTabs}
activeTabId={activeTabId}
onOpenPanel={(view) => handleRailClick(view as RailView)}
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
].includes(type)
) {
openSingletonTab(type, pendingEvent);
} else if (type === "local-terminal") {
openLocalTerminalTab();
} 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);
}
}}
/>
</Suspense>
)}
<TransferMonitor />
<Suspense fallback={null}>
<AlertManager userId={userId} loggedIn={!!username} />
</Suspense>
<OnboardingDialog
open={showOnboarding}
context={{ aiGloballyEnabled: onboardingAiEnabled }}
onClose={() => setShowOnboarding(false)}
/>
<DonationReminderModal
open={showDonationModal && !showOnboarding}
onDismiss={handleDismissDonationModal}
/>
</ServerStatusProvider>
);
}