Files
Termix/src/ui/AppShell.tsx
T

3261 lines
111 KiB
TypeScript
Raw Normal View History

2026-06-04 15:16:53 -04:00
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
2026-10-01 16:20:16 -05:00
import type { TabHandle } from "@termix/plugin-sdk/frontend";
2026-05-28 22:29:20 -05:00
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
2026-05-11 01:23:11 -05:00
import { Separator } from "@/components/separator";
import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet";
+5
2026-08-19 14:12:06 -05:00
import {
ChevronLeft,
ChevronRight,
2026-10-01 16:20:16 -05:00
Columns2,
+5
2026-08-19 14:12:06 -05:00
Maximize2,
Minimize2,
PanelRight,
RotateCcw,
2026-10-01 16:20:16 -05:00
Rows2,
+5
2026-08-19 14:12:06 -05:00
SquareArrowOutUpRight,
2026-10-01 16:20:16 -05:00
X,
+5
2026-08-19 14:12:06 -05:00
} from "lucide-react";
+8
2026-07-19 12:29:52 -05:00
import {
useState,
useRef,
useCallback,
useEffect,
+17
2026-09-20 14:56:39 -05:00
useLayoutEffect,
2026-10-01 16:20:16 -05:00
useMemo,
+8
2026-07-19 12:29:52 -05:00
createRef,
lazy,
Suspense,
} from "react";
2026-05-28 22:29:20 -05:00
import { createPortal } from "react-dom";
2026-05-11 01:23:11 -05:00
import { useIsMobile } from "@/hooks/use-mobile";
2026-10-01 16:20:16 -05:00
import { resetPermissionsCache } from "@/hooks/use-permissions";
2026-05-11 01:23:11 -05:00
import { MobileBottomBar } from "@/shell/MobileBottomBar";
+5
2026-08-19 14:12:06 -05:00
import { AppRail, type RailView } from "@/sidebar/AppRail";
2026-10-01 16:20:16 -05:00
import { ComponentSlot } from "@/shell/ActionSlot";
+5
2026-08-19 14:12:06 -05:00
import {
2026-10-01 16:20:16 -05:00
isCoreRailView,
+5
2026-08-19 14:12:06 -05:00
railItemLabel,
2026-10-01 16:20:16 -05:00
promotableIds,
rightDockableIds,
useRailItems,
+5
2026-08-19 14:12:06 -05:00
} 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";
2026-10-01 16:20:16 -05:00
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
import {
EmptyPanePicker,
type PickHostTarget,
} from "@/shell/split/EmptyPanePicker";
2026-05-28 22:29:20 -05:00
import { renderTabContent } from "@/shell/tabUtils";
2026-05-11 01:23:11 -05:00
import { TabBar } from "@/shell/TabBar";
2026-10-01 16:20:16 -05:00
import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
import {
dispatchCtrlW,
createCommandPaletteShortcutMatcher,
isShiftKey,
} from "@/lib/app-keyboard-shortcuts";
+17
2026-09-20 14:56:39 -05:00
import { parseCustomKeybindings } from "@/api/open-tabs-api";
import { findMatchingKeybinding } from "@/lib/keybinding-match";
import type {
CustomKeybinding,
KeybindingActionType,
} from "@/types/keybindings";
2026-10-01 16:20:16 -05:00
import {
GLOBAL_KEYBINDING_EVENT,
getKeybindingAction,
runKeybindingAction,
} from "@/shell/keybinding-registry";
+8
2026-07-19 12:29:52 -05:00
// 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 UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
2026-10-01 16:20:16 -05:00
const SyncPanel = lazy(() =>
import("@/settings/sync/SyncPanel").then((m) => ({
default: m.SyncPanel,
})),
);
+8
2026-07-19 12:29:52 -05:00
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
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>
);
}
2026-05-11 01:23:11 -05:00
import type {
Tab,
TabType,
Host,
HostFolder,
2026-06-04 15:16:53 -04:00
ThemeId,
FontSizeId,
+5
2026-08-19 14:12:06 -05:00
WorkspacePayload,
2026-05-11 01:23:11 -05:00
} from "@/types/ui-types";
2026-10-01 16:20:16 -05:00
import { applyAccentColor, applyFontSize } from "@/lib/theme";
2026-06-21 15:55:41 -05:00
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
+17
2026-09-20 14:56:39 -05:00
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
2026-06-04 15:16:53 -04:00
import { useTheme } from "@/components/theme-provider";
2026-05-28 22:05:25 -04:00
import {
getSSHHosts,
+2
2026-06-16 15:59:53 -05:00
getSSHFolders,
2026-05-28 22:05:25 -04:00
getUserInfo,
getOpenTabs,
addOpenTab,
deleteOpenTab,
patchOpenTab,
+8
2026-07-19 12:29:52 -05:00
createSSHHost,
2026-05-28 22:05:25 -04:00
getActiveSessions,
getUserPreferences,
+3
2026-08-06 14:41:39 -05:00
saveUserPreferences,
+8
2026-07-19 12:29:52 -05:00
dismissDonationModal,
2026-05-28 22:05:25 -04:00
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
+5
2026-08-19 14:12:06 -05:00
import {
2026-10-01 16:20:16 -05:00
buildLayoutPayload,
resolveLayoutSplits,
resolveLayoutTabTarget,
snapshotData,
} from "@/shell/shell-layout";
+8
2026-07-19 12:29:52 -05:00
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
2026-10-01 16:20:16 -05:00
import { useSyncStatus } from "@/hooks/use-sync-status";
import { rem, remScale } from "@/lib/rem";
2026-05-28 22:29:20 -05:00
import { dbHealthMonitor } from "@/lib/db-health-monitor";
2026-06-29 13:28:26 -05:00
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
+3
2026-08-06 14:41:39 -05:00
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
+8
2026-07-19 12:29:52 -05:00
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
+5
2026-08-19 14:12:06 -05:00
import { buildHostTree } from "@/sidebar/build-host-tree";
import {
2026-10-01 16:20:16 -05:00
addSplitTab,
canJoinSplit,
closeSplitTab,
isSplitTab,
makeSplitTab,
nextSplitNumber,
placeTabInPane,
removeTab,
+5
2026-08-19 14:12:06 -05:00
restoreSplitTabs,
serializeSplitTabs,
2026-10-01 16:20:16 -05:00
splitTabOf,
summarizeSplits,
updateSplit,
type SplitTab,
type TabSplitAction,
} from "@/shell/split/split-tabs";
import {
addPaneAtEdge,
assignTab,
canAddPane,
createPane,
createSplitNode,
createSplitState,
equalizeSplit,
findPane,
findPaneByTab,
focusPane,
listPanes,
MAX_PANES,
movePane,
neighborPane,
removePane,
resizeSplit,
shownPanes,
splitPane,
swapPanes,
toggleZoom,
type NavDirection,
type PaneEdge,
type PaneRect,
type SplitState,
} from "@/shell/split/split-tree";
import {
applyPreset,
buildPreset,
fromLegacyConfig,
type SplitPresetId,
} from "@/shell/split/split-presets";
import {
setSplitDropHandler,
type SplitDragSource,
type SplitDropHover,
} from "@/shell/split/split-drag";
import {
publishSplitTargets,
setSplitOpener,
type SplitOpenTarget,
} from "@/shell/split/split-targets";
import { registerPaletteEntry } from "@/shell/palette-registry";
import { createId } from "@/lib/create-id";
import {
canRestoreTabType,
getTabType,
isPersistentTabType,
isSessionTabType,
useTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { runHostAction } from "@/sidebar/host-contributions";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
notifyShellReady,
notifyTabsChanged,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
} from "@/plugin-host/shell-bridge";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
export { buildHostTree } from "@/sidebar/build-host-tree";
2026-05-11 01:23:11 -05:00
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ────────────────────────────────────────────────────────────────
export function AppShell({
username,
onLogout,
}: {
username: string;
+17
2026-09-20 14:56:39 -05:00
onLogout: (options?: { manual?: boolean }) => void;
2026-05-11 01:23:11 -05:00
}) {
2026-06-04 15:16:53 -04:00
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
2026-10-01 16:20:16 -05:00
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
const registeredPanels = usePanels();
const tabTypes = useTabTypes();
const { settled: pluginsSettled } = usePluginStore();
2026-05-28 22:29:20 -05:00
const [tabs, setTabs] = useState<Tab[]>([
2026-05-28 22:05:25 -04:00
{
id: "dashboard",
instanceId: "dashboard",
type: "dashboard",
label: t("nav.dashboard"),
openedAt: Date.now(),
},
2026-05-28 22:29:20 -05:00
]);
2026-05-11 01:23:11 -05:00
const [activeTabId, setActiveTabId] = useState("dashboard");
2026-05-28 22:05:25 -04:00
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);
2026-05-11 01:23:11 -05:00
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
2026-10-01 16:20:16 -05:00
// Split tabs are restored once their child tabs have stable live ids.
+2
2026-07-26 18:47:27 -05:00
const paneLayoutRestoredRef = useRef(false);
+5
2026-08-19 14:12:06 -05:00
const splitTabsRestoredRef = useRef(false);
2026-05-11 01:23:11 -05:00
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
2026-05-28 22:29:20 -05:00
const [hostsLoading, setHostsLoading] = useState(true);
2026-05-11 01:23:11 -05:00
const [allHosts, setAllHosts] = useState<Host[]>([]);
2026-10-01 16:20:16 -05:00
const allHostsRef = useRef(allHosts);
allHostsRef.current = allHosts;
2026-05-28 22:29:20 -05:00
const [isAdmin, setIsAdmin] = useState(false);
+5
2026-08-19 14:12:06 -05:00
// 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;
+8
2026-07-19 12:29:52 -05:00
const [showDonationModal, setShowDonationModal] = useState(false);
+5
2026-08-19 14:12:06 -05:00
const [showOnboarding, setShowOnboarding] = useState(false);
2026-05-28 22:05:25 -04:00
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
OpenTabRecord[]
>([]);
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
// 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;
/**
2026-10-01 16:20:16 -05:00
* Both onboarding entry points wait for plugins first, since plugins add
* steps of their own (the AI step, feature cards).
+5
2026-08-19 14:12:06 -05:00
*/
const loadOnboardingContext = useCallback(async () => {
2026-10-01 16:20:16 -05:00
const { settledPromise } = await import("@/plugin-host/plugin-store");
await settledPromise();
+5
2026-08-19 14:12:06 -05:00
}, []);
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]);
2026-05-11 01:23:11 -05:00
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState<RailView>("hosts");
2026-10-01 16:20:16 -05:00
// Host defaults open in the host manager, from anywhere (the admin panel,
// a folder's menu).
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setSidebarOpen(true);
setRailView("hosts");
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", { detail }),
);
}, 0);
};
window.addEventListener("termix:open-host-defaults", handler);
return () =>
window.removeEventListener("termix:open-host-defaults", handler);
}, []);
2026-05-28 22:05:25 -04:00
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
+2
2026-06-16 15:59:53 -05:00
return saved ? parseInt(saved, 10) : 291;
2026-05-28 22:05:25 -04:00
});
2026-05-11 01:23:11 -05:00
const [sidebarDragging, setSidebarDragging] = useState(false);
2026-05-28 22:29:20 -05:00
const [sidebarEditing, setSidebarEditing] = useState(false);
+3
2026-08-06 14:41:39 -05:00
const [settingsFullscreen, setSettingsFullscreen] = useState(false);
+5
2026-08-19 14:12:06 -05:00
// 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");
2026-10-01 16:20:16 -05:00
return saved && rightDockableIds().includes(saved)
+5
2026-08-19 14:12:06 -05:00
? (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"),
);
2026-06-29 13:28:26 -05:00
const [isAppFullscreen, setIsAppFullscreen] = useState(
() => !!document.fullscreenElement,
);
2026-05-11 01:23:11 -05:00
2026-05-28 22:05:25 -04:00
useEffect(() => {
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
}, [sidebarWidth]);
useEffect(() => {
+5
2026-08-19 14:12:06 -05:00
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
}, [rightSidebarWidth]);
2026-05-28 22:05:25 -04:00
useEffect(() => {
+5
2026-08-19 14:12:06 -05:00
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]);
2026-05-28 22:05:25 -04:00
2026-05-11 01:23:11 -05:00
const isMobile = useIsMobile();
+3
2026-08-06 14:41:39 -05:00
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]);
2026-05-11 01:23:11 -05:00
2026-05-28 22:29:20 -05:00
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
2026-05-11 01:23:11 -05:00
useEffect(() => {
2026-05-28 22:29:20 -05:00
if (isMobile) {
sidebarOpenBeforeMobile.current = sidebarOpen;
setSidebarOpen(false);
} else {
setSidebarOpen(sidebarOpenBeforeMobile.current);
}
2026-05-11 01:23:11 -05:00
}, [isMobile]);
2026-05-28 22:29:20 -05:00
useEffect(() => {
getUserInfo()
.then((info) => {
setIsAdmin(info.is_admin);
+8
2026-07-19 12:29:52 -05:00
setShowDonationModal(!!info.show_donation_modal);
})
2026-05-28 22:29:20 -05:00
.catch(() => setIsAdmin(false));
}, []);
+8
2026-07-19 12:29:52 -05:00
const handleDismissDonationModal = useCallback(() => {
setShowDonationModal(false);
dismissDonationModal().catch(() => {});
}, []);
2026-06-29 13:28:26 -05:00
const toggleAppFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
if (!document.fullscreenEnabled) {
2026-10-01 16:20:16 -05:00
toast.error(t("nav.fullscreenUnsupported"));
2026-06-29 13:28:26 -05:00
return;
}
await document.documentElement.requestFullscreen();
} catch {
2026-10-01 16:20:16 -05:00
toast.error(t("nav.fullscreenFailed"));
2026-06-29 13:28:26 -05:00
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
setIsAppFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
2026-06-21 15:55:41 -05:00
const tabsRef = useRef(tabs);
const activeTabIdRef = useRef(activeTabId);
+5
2026-08-19 14:12:06 -05:00
const closeActiveTabRef = useRef<() => void>(() => {});
+17
2026-09-20 14:56:39 -05:00
const globalKeybindingsRef = useRef<CustomKeybinding[]>([]);
2026-10-01 16:20:16 -05:00
// Split actions for handlers registered once, e.g. the global hotkeys.
const splitActionsRef = useRef<{
splitActive: (edge: "right" | "bottom") => void;
navigate: (direction: NavDirection) => boolean;
zoomFocused: () => boolean;
closeFocusedPane: () => void;
} | null>(null);
2026-06-21 15:55:41 -05:00
useEffect(() => {
tabsRef.current = tabs;
}, [tabs]);
+17
2026-09-20 14:56:39 -05:00
useEffect(() => {
let cancelled = false;
const load = () => {
getUserPreferences()
.then((prefs) => {
if (cancelled) return;
globalKeybindingsRef.current = parseCustomKeybindings(
prefs.customKeybindings,
2026-10-01 16:20:16 -05:00
).filter((binding) => binding.enabled);
+17
2026-09-20 14:56:39 -05:00
})
.catch(() => {});
};
load();
window.addEventListener("customKeybindingsChanged", load);
return () => {
cancelled = true;
window.removeEventListener("customKeybindingsChanged", load);
};
}, []);
useEffect(() => {
const runAction = (type: KeybindingActionType) => {
if (type === "openCommandPalette") {
setCommandPaletteOpen(true);
return;
}
2026-10-01 16:20:16 -05:00
if (type === "reconnectSession") {
const tabId = focusedSessionTabId();
if (!tabId) return;
const termRef = terminalRefs.current.get(tabId);
(termRef?.current as TabHandle | null)?.reconnect?.();
return;
}
const currentTabs = tabsRef.current.filter(
(tab) => !tab.parentSplitTabId,
);
+17
2026-09-20 14:56:39 -05:00
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;
2026-10-01 16:20:16 -05:00
// Only actions that run anywhere; a terminal's own are its to handle.
+17
2026-09-20 14:56:39 -05:00
const binding = findMatchingKeybinding(
event,
2026-10-01 16:20:16 -05:00
globalKeybindingsRef.current.filter(
(entry) => getKeybindingAction(entry.action.type)?.scope === "global",
),
+17
2026-09-20 14:56:39 -05:00
);
if (!binding) return;
event.preventDefault();
event.stopPropagation();
2026-10-01 16:20:16 -05:00
runKeybindingAction(binding.action);
+17
2026-09-20 14:56:39 -05:00
};
const handleAction = (event: Event) =>
runAction(
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
);
window.addEventListener("keydown", handleKeyDown, true);
2026-10-01 16:20:16 -05:00
window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
+17
2026-09-20 14:56:39 -05:00
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
2026-10-01 16:20:16 -05:00
window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
+17
2026-09-20 14:56:39 -05:00
};
}, []);
2026-06-21 15:55:41 -05:00
useEffect(() => {
activeTabIdRef.current = activeTabId;
}, [activeTabId]);
+5
2026-08-19 14:12:06 -05:00
useEffect(() => {
+17
2026-09-20 14:56:39 -05:00
return window.electronAPI?.onCloseActiveTab?.(() => {
if (dispatchCtrlW(document.activeElement)) return;
closeActiveTabRef.current();
});
+5
2026-08-19 14:12:06 -05:00
}, []);
2026-10-01 16:20:16 -05:00
/** The tab the user is working in: the focused pane's inside a split. */
function focusedSessionTabId(): string | null {
const id = activeTabIdRef.current;
const active = tabsRef.current.find((tab) => tab.id === id);
if (!isSplitTab(active)) return id || null;
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null;
}
+5
2026-08-19 14:12:06 -05:00
2026-10-01 16:20:16 -05:00
// ─── Where opened tabs go ─────────────────────────────────────────────────
const PENDING_TARGET_MS = 60_000;
/**
* The pane the next opened tab should fill, set when the user picks one
* (the empty pane picker, "Open in split"). `fresh` marks a pane created in
* the same click, which the last committed tabs do not show yet.
*/
const pendingPaneTargetRef = useRef<{
splitTabId: string;
paneId: string;
at: number;
fresh: boolean;
} | null>(null);
// Set while a saved layout is applied, so its tabs stay where it puts them.
const suppressPaneTargetRef = useRef(false);
function targetPane(splitTabId: string, paneId: string, fresh = false) {
pendingPaneTargetRef.current = {
splitTabId,
paneId,
at: Date.now(),
fresh,
};
}
/**
* Shows a newly opened (or re-focused) tab. It fills the pane the user
* picked, else the focused pane of the active split when that is empty,
* else it simply becomes the active tab.
*/
const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {});
placeOpenedTabRef.current = (tabId: string) => {
const current = tabsRef.current;
const pending = pendingPaneTargetRef.current;
pendingPaneTargetRef.current = null;
if (suppressPaneTargetRef.current || tabId === "dashboard") {
setActiveTabId(tabId);
+5
2026-08-19 14:12:06 -05:00
return;
}
2026-10-01 16:20:16 -05:00
const existing = current.find((tab) => tab.id === tabId);
let target: { splitTabId: string; paneId: string } | null = null;
if (pending && Date.now() - pending.at < PENDING_TARGET_MS) {
const split = current.find((tab) => tab.id === pending.splitTabId);
if (
pending.fresh ||
(isSplitTab(split) && findPane(split.split, pending.paneId))
) {
target = pending;
}
}
// Opening a session that already sits in a split shows it there.
if (!target && existing?.parentSplitTabId) {
setActiveTabId(tabId);
return;
}
if (!target) {
const active = current.find((tab) => tab.id === activeTabIdRef.current);
if (isSplitTab(active)) {
const focused = findPane(active.split, active.split.focusedPaneId);
if (focused && focused.tabId === null) {
target = { splitTabId: active.id, paneId: focused.id };
}
}
}
if (!target || (existing && !canJoinSplit(existing))) {
setActiveTabId(tabId);
return;
}
const { splitTabId, paneId } = target;
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
setActiveTabId(splitTabId);
};
// Panels that type into a terminal act on "the terminal you're working in".
+5
2026-08-19 14:12:06 -05:00
// Once those panels can themselves be the active tab, activeTabId points at
// the panel and the lookup misses, so remember the last terminal instead.
2026-10-01 16:20:16 -05:00
// In a split, the tab being worked in is the focused pane's.
const workingTabId = (() => {
const active = tabs.find((t) => t.id === activeTabId);
if (!isSplitTab(active)) return activeTabId;
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? "";
})();
+5
2026-08-19 14:12:06 -05:00
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
useEffect(() => {
2026-10-01 16:20:16 -05:00
const working = tabs.find((t) => t.id === workingTabId);
if (working && getTabType(working.type)?.commandTarget) {
setLastTerminalTabId(working.id);
}
}, [workingTabId, tabs]);
2026-05-28 22:05:25 -04:00
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
useState<boolean>(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
2026-10-01 16:20:16 -05:00
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
() => localStorage.getItem("showTabNumbers") === "true",
);
2026-05-28 22:29:20 -05:00
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
new Map(),
);
2026-10-01 16:20:16 -05:00
// Each shown pane's content element, by pane id. The version bump
// re-renders so the placement effect moves tab nodes into new panes.
const paneElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const [, setPaneElsVersion] = useState(0);
2026-05-28 22:29:20 -05:00
// 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);
2026-10-01 16:20:16 -05:00
// The area below the tab bar, where tabs and panes are dropped to split.
const mainAreaRef = useRef<HTMLDivElement>(null);
+17
2026-09-20 14:56:39 -05:00
// 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);
2026-05-28 22:29:20 -05:00
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)!;
}, []);
+17
2026-09-20 14:56:39 -05:00
// 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);
2026-05-28 22:29:20 -05:00
const onPaneContentRef = useCallback(
2026-10-01 16:20:16 -05:00
(paneId: string, el: HTMLDivElement | null) => {
const els = paneElsRef.current;
if (el) {
if (els.get(paneId) === el) return;
els.set(paneId, el);
} else {
if (!els.has(paneId)) return;
els.delete(paneId);
}
setPaneElsVersion((version) => version + 1);
2026-05-28 22:29:20 -05:00
},
[],
);
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
// 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);
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
// Double-shift or Ctrl+K opens the command palette. Double-shift alone was
// hard to discover.
2026-05-11 01:23:11 -05:00
useEffect(() => {
2026-10-01 16:20:16 -05:00
if (!commandPaletteShortcutEnabled) return;
const shortcut = createCommandPaletteShortcutMatcher();
2026-05-11 01:23:11 -05:00
const handleKeyDown = (e: KeyboardEvent) => {
2026-10-01 16:20:16 -05:00
if (!shortcut.matches(e)) return;
if (!isShiftKey(e)) e.preventDefault();
setCommandPaletteOpen((prev) => !prev);
2026-05-11 01:23:11 -05:00
};
window.addEventListener("keydown", handleKeyDown);
2026-10-01 16:20:16 -05:00
window.addEventListener("blur", shortcut.reset);
window.addEventListener("compositionstart", shortcut.reset);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", shortcut.reset);
window.removeEventListener("compositionstart", shortcut.reset);
};
2026-05-28 22:05:25 -04:00
}, [commandPaletteShortcutEnabled]);
+5
2026-08-19 14:12:06 -05:00
// 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]);
2026-06-21 15:55:41 -05:00
// 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) => {
2026-06-29 13:28:26 -05:00
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
if (e.code === "KeyF") {
e.preventDefault();
toggleAppFullscreen();
return;
}
}
2026-10-01 16:20:16 -05:00
// Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter
// zooms the focused pane.
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
if (e.code === "Backslash" || e.code === "Minus") {
2026-06-21 15:55:41 -05:00
e.preventDefault();
2026-10-01 16:20:16 -05:00
splitActionsRef.current?.splitActive(
e.code === "Backslash" ? "right" : "bottom",
);
return;
}
if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) {
e.preventDefault();
return;
}
}
2026-06-21 15:55:41 -05:00
2026-10-01 16:20:16 -05:00
// Alt+Arrow moves between panes of the active split
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
const direction = (
{
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
} as Record<string, NavDirection>
)[e.code];
if (direction && splitActionsRef.current?.navigate(direction)) {
e.preventDefault();
2026-06-21 15:55:41 -05:00
return;
}
+17
2026-09-20 14:56:39 -05:00
}
+5
2026-08-19 14:12:06 -05:00
+17
2026-09-20 14:56:39 -05:00
// Cmd+1..9 on macOS, Alt+1..9 elsewhere — jump directly to the tab at that position
const tabDigit = getTabJumpDigit(e);
if (tabDigit !== null) {
2026-10-01 16:20:16 -05:00
const currentTabs = tabsRef.current.filter(
(tab) => !tab.parentSplitTabId,
);
+17
2026-09-20 14:56:39 -05:00
const index = tabDigit - 1;
if (index < currentTabs.length) {
e.preventDefault();
setActiveTabId(currentTabs[index].id);
+5
2026-08-19 14:12:06 -05:00
}
+17
2026-09-20 14:56:39 -05:00
return;
2026-06-21 15:55:41 -05:00
}
// 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();
2026-10-01 16:20:16 -05:00
const currentTabs = tabsRef.current.filter(
(tab) => !tab.parentSplitTabId,
);
2026-06-21 15:55:41 -05:00
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);
};
}, []);
2026-05-28 22:05:25 -04:00
useEffect(() => {
const handler = () => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
};
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
return () =>
window.removeEventListener(
"commandPaletteShortcutEnabledChanged",
handler,
);
2026-05-11 01:23:11 -05:00
}, []);
2026-10-01 16:20:16 -05:00
useEffect(() => {
const handler = () => {
setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true");
};
window.addEventListener("showTabNumbersChanged", handler);
return () => window.removeEventListener("showTabNumbersChanged", handler);
}, []);
2026-05-11 01:23:11 -05:00
useEffect(() => {
+17
2026-09-20 14:56:39 -05:00
const handle = (event: Event) => {
const manual =
event instanceof CustomEvent &&
(event.detail as { manual?: boolean } | undefined)?.manual === true;
2026-10-01 16:20:16 -05:00
// Drop the cached grants so the next user never inherits them.
resetPermissionsCache();
+17
2026-09-20 14:56:39 -05:00
onLogout(manual ? { manual: true } : undefined);
};
2026-05-11 01:23:11 -05:00
window.addEventListener("termix:logout", handle);
return () => window.removeEventListener("termix:logout", handle);
}, [onLogout]);
2026-05-28 22:29:20 -05:00
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]);
2026-05-28 22:05:25 -04:00
useEffect(() => {
getUserPreferences()
2026-06-04 15:16:53 -04:00
.then((prefs) => {
+3
2026-08-06 14:41:39 -05:00
const loginLanguage = consumeLoginLanguage();
2026-06-04 15:16:53 -04:00
setUserPrefs(prefs);
+2
2026-06-16 15:59:53 -05:00
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",
+17
2026-09-20 14:56:39 -05:00
"termix-ui-font",
+2
2026-06-16 15:59:53 -05:00
"i18nextLng",
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
"pinAppRail",
2026-06-29 13:28:26 -05:00
"expandAppRailOnHover",
+2
2026-06-16 15:59:53 -05:00
"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);
}
+3
2026-08-06 14:41:39 -05:00
const preferredLanguage = loginLanguage ?? prefs.language;
if (preferredLanguage && preferredLanguage !== i18n.language) {
void changeAppLanguage(preferredLanguage);
}
if (loginLanguage && loginLanguage !== prefs.language) {
void saveUserPreferences({ language: loginLanguage });
+2
2026-06-16 15:59:53 -05:00
}
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),
);
2026-06-29 13:28:26 -05:00
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
+2
2026-06-16 15:59:53 -05:00
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
2026-06-29 13:28:26 -05:00
window.dispatchEvent(new Event("pinAppRailChanged"));
}
if (
prefs.expandAppRailOnHover !== null &&
prefs.expandAppRailOnHover !== undefined
) {
localStorage.setItem(
"expandAppRailOnHover",
String(prefs.expandAppRailOnHover),
);
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
}
+2
2026-06-16 15:59:53 -05:00
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"));
}
2026-06-04 15:16:53 -04:00
}
})
2026-05-28 22:05:25 -04:00
.catch(() => {})
.finally(() => setUserPrefsLoaded(true));
}, []);
2026-05-11 01:23:11 -05:00
// Load real hosts from API
const loadHosts = useCallback(async () => {
try {
+2
2026-06-16 15:59:53 -05:00
const [raw, folders] = await Promise.all([
getSSHHosts(),
getSSHFolders().catch(() => []),
]);
2026-05-11 01:23:11 -05:00
const converted = raw.map(sshHostToHost);
setAllHosts(converted);
+2
2026-07-26 18:47:27 -05:00
const folderMeta = new Map<
string,
+5
2026-08-19 14:12:06 -05:00
{
color?: string;
icon?: string;
credentialId?: number | null;
sortOrder?: number | null;
2026-10-01 16:20:16 -05:00
localOnly?: boolean;
+5
2026-08-19 14:12:06 -05:00
}
+2
2026-07-26 18:47:27 -05:00
>();
+2
2026-06-16 15:59:53 -05:00
for (const f of folders) {
folderMeta.set(f.name, {
color: f.color ?? undefined,
icon: f.icon ?? undefined,
+2
2026-07-26 18:47:27 -05:00
credentialId: f.credentialId ?? null,
+5
2026-08-19 14:12:06 -05:00
sortOrder: f.sortOrder ?? null,
2026-10-01 16:20:16 -05:00
localOnly: !!f.localOnly,
+2
2026-06-16 15:59:53 -05:00
});
}
setRealHostTree(buildHostTree(raw, folderMeta));
2026-05-11 01:23:11 -05:00
} catch {
// Keep empty state on error
2026-05-28 22:29:20 -05:00
} finally {
setHostsLoading(false);
2026-05-28 22:05:25 -04:00
setHostsLoaded(true);
2026-05-11 01:23:11 -05:00
}
}, []);
useEffect(() => {
loadHosts();
}, [loadHosts]);
2026-05-28 22:29:20 -05:00
useEffect(() => {
+8
2026-07-19 12:29:52 -05:00
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);
};
2026-05-28 22:29:20 -05:00
}, [loadHosts]);
2026-10-01 16:20:16 -05:00
// Keeps the desktop's sync status polled while the app is open; a pass
// that changed data tells the panels to reload.
useSyncStatus();
+5
2026-08-19 14:12:06 -05:00
2026-05-11 01:23:11 -05:00
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
useEffect(() => {
if (allHosts.length === 0) return;
2026-10-01 16:20:16 -05:00
const byId = new Map(allHosts.map((h) => [h.id, h]));
setTabs((prev) => {
let changed = false;
const next = prev.map((t) => {
const fresh = t.host ? byId.get(t.host.id) : undefined;
if (!fresh || fresh === t.host) return t;
// A reload hands back new objects; only replace hosts that changed so
// untouched tabs keep their identity and skip a re-render.
if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t;
changed = true;
return { ...t, host: fresh };
});
return changed ? next : prev;
});
2026-05-28 22:29:20 -05:00
}, [allHosts]);
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
function buildWorkspacePayload(): WorkspacePayload {
2026-10-01 16:20:16 -05:00
return buildLayoutPayload({
+5
2026-08-19 14:12:06 -05:00
tabs,
activeTabId,
sidebar: {
left: { view: railView, open: sidebarOpen, width: sidebarWidth },
right: {
view: rightRailView,
open: rightRailView !== null,
width: rightSidebarWidth,
},
},
});
}
2026-10-01 16:20:16 -05:00
async function applyLayout(
payload: WorkspacePayload,
name: string,
): Promise<{ skipped: string[] }> {
+5
2026-08-19 14:12:06 -05:00
// Tear down the current arrangement the same way an individual tab close does.
for (const tab of [...tabsRef.current]) {
doCloseTab(tab.id);
}
2026-10-01 16:20:16 -05:00
// Tabs reopened here must not be pulled into a pane of the old layout.
suppressPaneTargetRef.current = true;
pendingPaneTargetRef.current = null;
try {
const slotIdToNewTabId = new Map<string, string>();
const skippedTabs: string[] = [];
+5
2026-08-19 14:12:06 -05:00
2026-10-01 16:20:16 -05:00
for (const snapshot of payload.tabs) {
const target = resolveLayoutTabTarget(snapshot, allHostsRef.current);
+5
2026-08-19 14:12:06 -05:00
2026-10-01 16:20:16 -05:00
if (target.kind === "skip") {
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
continue;
}
+5
2026-08-19 14:12:06 -05:00
2026-10-01 16:20:16 -05:00
if (target.kind === "singleton") {
const newTabId = openSingletonTab(
snapshot.type,
undefined,
target.host,
snapshotData(snapshot),
);
slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type);
continue;
}
if (target.kind === "host") {
const newTabId = openTab(
target.host,
snapshot.type,
{
instanceId: createId(),
restoredSessionId: null,
savedLabel: snapshot.customLabel ?? snapshot.label,
},
{ data: snapshotData(snapshot) },
);
slotIdToNewTabId.set(snapshot.slotId, newTabId);
}
+5
2026-08-19 14:12:06 -05:00
}
2026-10-01 16:20:16 -05:00
const splitIdBySlotId = new Map<string, string>();
const splitIdByTabId = new Map<string, string>();
let firstSplitId: string | null = null;
resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => {
const splitTab = makeSplitTab(
createId(),
saved.label || name || splitLabel(index + 1),
saved.split,
+5
2026-08-19 14:12:06 -05:00
);
2026-10-01 16:20:16 -05:00
if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id);
for (const pane of listPanes(saved.split.root)) {
if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id);
}
firstSplitId ??= splitTab.id;
setTabs((prev) => addSplitTab(prev, splitTab));
});
// A tab that now sits in a split is shown through its split.
const activeSlot = payload.activeSlotId;
const activeTabInLayout = activeSlot
? slotIdToNewTabId.get(activeSlot)
: undefined;
const activeId =
(activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ??
(activeTabInLayout
? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout)
: undefined) ??
firstSplitId ??
"dashboard";
setActiveTabId(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 === "split-screen"
? "hosts"
: 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);
+5
2026-08-19 14:12:06 -05:00
}
2026-10-01 16:20:16 -05:00
return { skipped: skippedTabs };
} finally {
suppressPaneTargetRef.current = false;
+5
2026-08-19 14:12:06 -05:00
}
}
2026-05-28 22:05:25 -04:00
// 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(() => {
2026-10-01 16:20:16 -05:00
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
2026-05-28 22:05:25 -04:00
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) =>
2026-10-01 16:20:16 -05:00
isPersistentTabType(t.type),
2026-05-28 22:05:25 -04:00
);
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;
2026-10-01 16:20:16 -05:00
if (!canRestoreTabType(saved.tabType, host)) continue;
2026-06-04 15:16:53 -04:00
2026-05-28 22:05:25 -04:00
// 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;
2026-06-21 15:55:41 -05:00
const isCustomLabel =
host &&
saved.label !== host.name &&
!/^.+ \(\d+\)$/.test(saved.label);
2026-05-28 22:05:25 -04:00
restoredTabs.push({
id: tabId,
instanceId: saved.id,
type: saved.tabType as TabType,
label: saved.label,
2026-06-21 15:55:41 -05:00
customLabel: isCustomLabel ? saved.label : undefined,
2026-05-28 22:05:25 -04:00
host,
openedAt: new Date(saved.createdAt).getTime(),
restoredSessionId,
2026-10-01 16:20:16 -05:00
terminalRef: isSessionTabType(saved.tabType)
2026-06-29 13:28:26 -05:00
? createRef()
: undefined,
2026-05-28 22:05:25 -04:00
});
}
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();
2026-10-01 16:20:16 -05:00
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
2026-05-28 22:05:25 -04:00
2026-10-01 16:20:16 -05:00
// Plugins that act once the session is back (the workspaces plugin applies
// a default workspace) wait for this.
+5
2026-08-19 14:12:06 -05:00
useEffect(() => {
2026-10-01 16:20:16 -05:00
if (tabsReady) notifyShellReady();
+5
2026-08-19 14:12:06 -05:00
}, [tabsReady]);
2026-10-01 16:20:16 -05:00
// Restore split tabs once their child sessions have stable live ids. The
// oldest single-split keys are migrated once into a first split.
+2
2026-07-26 18:47:27 -05:00
useEffect(() => {
if (!tabsReady || paneLayoutRestoredRef.current) return;
paneLayoutRestoredRef.current = true;
try {
+5
2026-08-19 14:12:06 -05:00
const savedSplitTabs = JSON.parse(
localStorage.getItem("termix_splitTabs") ?? "[]",
2026-10-01 16:20:16 -05:00
) as unknown;
+5
2026-08-19 14:12:06 -05:00
if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) {
setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev));
return;
}
2026-10-01 16:20:16 -05:00
const savedInstanceIds = JSON.parse(
+2
2026-07-26 18:47:27 -05:00
localStorage.getItem("termix_paneInstanceIds") ?? "null",
2026-10-01 16:20:16 -05:00
) as unknown;
const savedMode = localStorage.getItem("termix_splitMode");
if (!Array.isArray(savedInstanceIds) || !savedMode) return;
const savedSizes = JSON.parse(
localStorage.getItem("termix_paneSizes") ?? "null",
) as { rowSizes?: unknown; rowColSizes?: unknown } | null;
const tabIdByInstanceId = new Map(
tabs.map((tab) => [tab.instanceId, tab.id]),
+2
2026-07-26 18:47:27 -05:00
);
2026-10-01 16:20:16 -05:00
const root = fromLegacyConfig(
savedMode,
savedInstanceIds.map((instanceId) =>
typeof instanceId === "string"
? (tabIdByInstanceId.get(instanceId) ?? null)
: null,
),
savedSizes?.rowSizes,
savedSizes?.rowColSizes,
);
if (root && listPanes(root).some((pane) => pane.tabId)) {
const splitTab = makeSplitTab(
createId(),
splitLabel(1),
createSplitState(root),
);
setTabs((prev) => addSplitTab(prev, splitTab));
setActiveTabId(splitTab.id);
+2
2026-07-26 18:47:27 -05:00
}
} catch {
// silently fail
+5
2026-08-19 14:12:06 -05:00
} finally {
splitTabsRestoredRef.current = true;
localStorage.removeItem("termix_splitMode");
localStorage.removeItem("termix_paneInstanceIds");
localStorage.removeItem("termix_paneSizes");
+2
2026-07-26 18:47:27 -05:00
}
}, [tabsReady, tabs]);
2026-05-28 22:05:25 -04:00
// 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;
2026-10-01 16:20:16 -05:00
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
2026-05-28 22:05:25 -04:00
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]);
2026-10-01 16:20:16 -05:00
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
// keep its last-session snapshot current.
+5
2026-08-19 14:12:06 -05:00
useEffect(() => {
2026-10-01 16:20:16 -05:00
if (tabsReady) notifyTabsChanged();
+5
2026-08-19 14:12:06 -05:00
}, [
tabs,
tabsReady,
railView,
sidebarOpen,
sidebarWidth,
rightRailView,
rightSidebarWidth,
]);
2026-05-11 01:23:11 -05:00
// ─── Tab management ──────────────────────────────────────────────────────
2026-05-28 22:05:25 -04:00
const openTab = useCallback(function openTab(
host: Host,
type: TabType,
2026-06-21 15:55:41 -05:00
restore?: {
instanceId: string;
restoredSessionId: string | null;
savedLabel?: string;
},
2026-10-01 16:20:16 -05:00
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
2026-05-28 22:05:25 -04:00
) {
2026-10-01 16:20:16 -05:00
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) {
placeOpenedTabRef.current(existing.id);
return existing.id;
}
}
const instanceId = restore?.instanceId ?? createId();
// Unique per open; a timestamp collides when a workspace opens several
// tabs in the same millisecond.
const tabId = `${type}-${createId()}`;
2026-05-28 22:05:25 -04:00
const openedAt = Date.now();
2026-10-01 16:20:16 -05:00
const ref = isSessionTabType(type) ? createRef() : undefined;
2026-05-28 22:29:20 -05:00
if (ref) terminalRefs.current.set(tabId, ref);
2026-05-28 22:05:25 -04:00
let finalLabel = host.name;
2026-06-21 15:55:41 -05:00
const savedLabel = restore?.savedLabel;
// 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);
2026-05-11 01:23:11 -05:00
setTabs((prev) => {
2026-06-21 15:55:41 -05:00
if (isCustomLabel && savedLabel) {
finalLabel = savedLabel;
return [
...prev,
{
id: tabId,
instanceId,
type,
label: finalLabel,
customLabel: finalLabel,
host,
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
2026-10-01 16:20:16 -05:00
data: options?.data,
2026-06-21 15:55:41 -05:00
},
];
}
2026-05-11 01:23:11 -05:00
const same = prev.filter(
(t) =>
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
);
+17
2026-09-20 14:56:39 -05:00
finalLabel = options?.label
? options.label
: same.length === 0
? host.name
: `${host.name} (${same.length + 1})`;
2026-05-28 22:29:20 -05:00
// 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;
2026-05-28 22:05:25 -04:00
return [
...next,
{
id: tabId,
instanceId,
type,
label: finalLabel,
host,
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
2026-10-01 16:20:16 -05:00
data: options?.data,
2026-05-28 22:05:25 -04:00
},
];
2026-05-11 01:23:11 -05:00
});
2026-10-01 16:20:16 -05:00
placeOpenedTabRef.current(tabId);
2026-05-28 22:05:25 -04:00
2026-10-01 16:20:16 -05:00
if (isPersistentTabType(type)) {
2026-05-28 22:05:25 -04:00
addOpenTab({
id: instanceId,
tabType: type,
hostId: host ? parseInt(host.id) : null,
label: finalLabel,
tabOrder: 0,
}).catch(() => {});
}
+5
2026-08-19 14:12:06 -05:00
return tabId;
2026-05-28 22:29:20 -05:00
}, []);
2026-05-11 01:23:11 -05:00
+17
2026-09-20 14:56:39 -05:00
function connectHost(
host: Host,
preferredType?: TabType,
2026-10-01 16:20:16 -05:00
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
+17
2026-09-20 14:56:39 -05:00
) {
2026-06-29 13:28:26 -05:00
const type = resolveHostTabType(host, preferredType);
2026-10-01 16:20:16 -05:00
if (!type) return;
+17
2026-09-20 14:56:39 -05:00
openTab(host, type, undefined, options);
2026-05-11 01:23:11 -05:00
}
+8
2026-07-19 12:29:52 -05:00
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],
);
2026-10-01 16:20:16 -05:00
/** A tab type that opens a fresh tab every time (a local shell). */
function openMultiInstanceTab(
type: TabType,
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
): string {
const instanceId = createId();
const id = `${type}-${instanceId}`;
const titleKey = getTabType(type)?.titleKey;
const title = options?.label ?? (titleKey ? t(titleKey) : type);
+5
2026-08-19 14:12:06 -05:00
setTabs((current) => {
2026-10-01 16:20:16 -05:00
const count = current.filter((tab) => tab.type === type).length;
+5
2026-08-19 14:12:06 -05:00
return [
...current,
{
id,
instanceId,
2026-10-01 16:20:16 -05:00
type,
+5
2026-08-19 14:12:06 -05:00
label:
2026-10-01 16:20:16 -05:00
count === 0 || options?.label ? title : `${title} (${count + 1})`,
+5
2026-08-19 14:12:06 -05:00
openedAt: Date.now(),
2026-10-01 16:20:16 -05:00
...(options?.host ? { host: options.host } : {}),
...(options?.data !== undefined ? { data: options.data } : {}),
+5
2026-08-19 14:12:06 -05:00
},
];
});
2026-10-01 16:20:16 -05:00
placeOpenedTabRef.current(id);
+5
2026-08-19 14:12:06 -05:00
return id;
}
2026-05-28 22:29:20 -05:00
const openSingletonTab = useCallback(
2026-10-01 16:20:16 -05:00
// `host` optionally preselects a host for a singleton plugin tab.
+2
2026-06-16 15:59:53 -05:00
function openSingletonTab(
type: TabType,
pendingEvent?: string,
host?: Host,
2026-10-01 16:20:16 -05:00
data?: Record<string, unknown>,
tabLabel?: string,
): string | undefined {
// A local shell is never a singleton: each open is its own session.
if (getTabType(type)?.multiInstance) {
return openMultiInstanceTab(type, { host, data, label: tabLabel });
+5
2026-08-19 14:12:06 -05:00
}
2026-05-28 22:29:20 -05:00
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
setSidebarOpen(true);
setRailView("credentials");
setTimeout(
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
),
0,
);
2026-06-21 15:55:41 -05:00
} else if (pendingEvent === "host-manager:show-credentials") {
setSidebarOpen(true);
setRailView("credentials");
2026-05-28 22:29:20 -05:00
} else {
setSidebarOpen(true);
setRailView("hosts");
if (pendingEvent) {
setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
0,
);
}
}
return;
2026-05-11 01:23:11 -05:00
}
2026-05-28 22:29:20 -05:00
if (type === "user-profile" || type === "admin-settings") {
setSidebarEditing(false);
setRailView(type as RailView);
setSidebarOpen(true);
return;
}
const id = type;
2026-05-28 22:05:25 -04:00
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
};
2026-10-01 16:20:16 -05:00
// 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));
2026-05-28 22:29:20 -05:00
setTabs((prev) => {
+2
2026-06-16 15:59:53 -05:00
const existing = prev.find((t) => t.id === id);
if (existing) {
2026-10-01 16:20:16 -05:00
// Refocusing a singleton with a host or data passes it on.
if (!host && data === undefined) return prev;
+5
2026-08-19 14:12:06 -05:00
return prev.map((t) =>
t.id === id
? {
...t,
...(host ? { host } : {}),
2026-10-01 16:20:16 -05:00
...(data !== undefined ? { data } : {}),
+5
2026-08-19 14:12:06 -05:00
}
: t,
);
+2
2026-06-16 15:59:53 -05:00
}
2026-05-28 22:05:25 -04:00
return [
...prev,
{
id,
instanceId: id,
type,
+5
2026-08-19 14:12:06 -05:00
label,
2026-05-28 22:05:25 -04:00
openedAt: Date.now(),
2026-10-01 16:20:16 -05:00
...(host ? { host } : {}),
...(data !== undefined ? { data } : {}),
2026-05-28 22:05:25 -04:00
},
];
2026-05-28 22:29:20 -05:00
});
2026-10-01 16:20:16 -05:00
placeOpenedTabRef.current(id);
if (isPersistentTabType(type)) {
2026-05-28 22:05:25 -04:00
addOpenTab({
id,
tabType: type,
hostId: null,
+5
2026-08-19 14:12:06 -05:00
label,
2026-05-28 22:05:25 -04:00
tabOrder: 0,
}).catch(() => {});
}
2026-10-01 16:20:16 -05:00
return id;
2026-05-28 22:29:20 -05:00
},
2026-10-01 16:20:16 -05:00
[t],
2026-05-28 22:29:20 -05:00
);
2026-06-29 13:28:26 -05:00
const getTabCloseLabel = useCallback((tab: Tab) => {
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
}, []);
const isActiveConnectionTab = useCallback((tab: Tab) => {
2026-10-01 16:20:16 -05:00
if (!isSessionTabType(tab.type)) return false;
2026-06-29 13:28:26 -05:00
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]);
2026-05-28 22:29:20 -05:00
function doCloseTab(id: string) {
2026-05-28 22:05:25 -04:00
const tabToClose = tabs.find((t) => t.id === id);
+2
2026-06-16 15:59:53 -05:00
if (tabToClose?.terminalRef?.current?.disconnect) {
tabToClose.terminalRef.current.disconnect();
}
2026-10-01 16:20:16 -05:00
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
2026-05-28 22:05:25 -04:00
deleteOpenTab(tabToClose.instanceId).catch(() => {});
}
2026-05-28 22:29:20 -05:00
terminalRefs.current.delete(id);
2026-10-01 16:20:16 -05:00
if (isSplitTab(tabToClose)) {
unsplit(id);
return;
}
2026-05-28 22:29:20 -05:00
if (id === activeTabId) {
+5
2026-08-19 14:12:06 -05:00
const remaining = tabs.filter(
(tab) => tab.id !== id && !tab.parentSplitTabId,
);
2026-05-28 22:29:20 -05:00
setActiveTabId(
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
);
2026-05-11 01:23:11 -05:00
}
setTabs((prev) => {
2026-10-01 16:20:16 -05:00
// A pane that held the tab stays, empty and focused, ready for the next one.
const next = removeTab(prev, id);
2026-05-28 22:29:20 -05:00
if (next.length === 0)
return [
2026-05-28 22:05:25 -04:00
{
id: "dashboard",
instanceId: "dashboard",
type: "dashboard",
label: t("nav.dashboard"),
openedAt: Date.now(),
},
2026-05-28 22:29:20 -05:00
];
return next;
2026-05-11 01:23:11 -05:00
});
2026-05-28 22:29:20 -05:00
}
2026-10-01 16:20:16 -05:00
const reconnectAllRef = useRef<() => void>(() => {});
function reconnectAllDisconnected() {
const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current);
if (reconnected || !failed)
toast(
t(
reconnected
? "nav.reconnectingTerminals"
: "nav.noDisconnectedTerminals",
{ count: reconnected },
),
2026-05-28 22:29:20 -05:00
);
2026-10-01 16:20:16 -05:00
if (failed)
toast.error(t("nav.reconnectTerminalsFailed", { count: failed }));
2026-05-11 01:23:11 -05:00
}
2026-10-01 16:20:16 -05:00
reconnectAllRef.current = reconnectAllDisconnected;
function refreshTab(id: string) {
+2
2026-07-26 18:47:27 -05:00
const tab = tabs.find((t) => t.id === id);
2026-10-01 16:20:16 -05:00
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?.();
+2
2026-07-26 18:47:27 -05:00
}
2026-05-11 01:23:11 -05:00
function closeTab(id: string) {
2026-05-28 22:29:20 -05:00
const tab = tabs.find((t) => t.id === id);
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
2026-06-29 13:28:26 -05:00
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
const closeLabel = getTabCloseLabel(tab);
const toastId = `close-tab-${id}`;
2026-10-01 16:20:16 -05:00
toast(t("nav.confirmCloseHost", { host: closeLabel }), {
id: toastId,
duration: 8000,
action: {
label: t("nav.close"),
onClick: () => {
toast.dismiss(toastId);
doCloseTab(id);
2026-06-29 13:28:26 -05:00
},
2026-05-28 22:29:20 -05:00
},
2026-10-01 16:20:16 -05:00
cancel: {
label: t("nav.cancel"),
onClick: () => toast.dismiss(toastId),
},
});
2026-05-28 22:29:20 -05:00
return;
}
2026-06-29 13:28:26 -05:00
2026-10-01 16:20:16 -05:00
if (tab && isSessionTabType(tab.type) && confirmEnabled) {
2026-06-29 13:28:26 -05:00
toast.dismiss(`close-tab-${id}`);
}
2026-05-28 22:29:20 -05:00
doCloseTab(id);
2026-05-11 01:23:11 -05:00
}
2026-10-01 16:20:16 -05:00
// In a split, closing acts on the focused pane: its session, or the pane
// itself once it is empty.
+5
2026-08-19 14:12:06 -05:00
closeActiveTabRef.current = () => {
const id = activeTabIdRef.current;
2026-10-01 16:20:16 -05:00
const active = tabsRef.current.find((tab) => tab.id === id);
if (isSplitTab(active)) {
const tabId = focusedSessionTabId();
if (tabId) closeTab(tabId);
else closePaneOf(active.id, active.split.focusedPaneId);
return;
}
+5
2026-08-19 14:12:06 -05:00
if (id !== "dashboard") closeTab(id);
};
2026-06-21 15:55:41 -05:00
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);
+5
2026-08-19 14:12:06 -05:00
if (tab?.instanceId && tab.type !== "split-screen") {
2026-06-21 15:55:41 -05:00
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
}
}
2026-10-01 16:20:16 -05:00
// ─── Split screen ────────────────────────────────────────────────────────
2026-05-28 22:05:25 -04:00
2026-10-01 16:20:16 -05:00
const splitLabel = (number: number) =>
t("splitScreen.defaultLabel", { number });
2026-05-28 22:05:25 -04:00
2026-10-01 16:20:16 -05:00
/**
* Edits a split with a change computed from the last committed state, for
* edits that create panes and so have to know the new pane's id.
*/
function commitSplit(
splitTabId: string,
fn: (state: SplitState) => SplitState | null,
): SplitState | null {
const current = tabsRef.current.find((tab) => tab.id === splitTabId);
if (!isSplitTab(current)) return null;
const next = fn(current.split);
setTabs((prev) =>
updateSplit(prev, splitTabId, (state) =>
state === current.split ? next : fn(state),
),
+5
2026-08-19 14:12:06 -05:00
);
2026-10-01 16:20:16 -05:00
return next;
+5
2026-08-19 14:12:06 -05:00
}
2026-10-01 16:20:16 -05:00
function editSplit(
splitTabId: string,
fn: (state: SplitState) => SplitState | null,
) {
setTabs((prev) => updateSplit(prev, splitTabId, fn));
2026-05-28 22:05:25 -04:00
}
2026-10-01 16:20:16 -05:00
function warnSplitFull() {
toast.error(t("splitScreen.maxPanes", { count: MAX_PANES }));
}
function openSplitTab(state: SplitState): SplitTab {
const number = nextSplitNumber(tabsRef.current, splitLabel);
const splitTab = makeSplitTab(createId(), splitLabel(number), state);
setTabs((prev) => addSplitTab(prev, splitTab));
setActiveTabId(splitTab.id);
return splitTab;
}
/** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */
function startSplit(tabId: string | null, edge: PaneEdge): SplitTab {
const own = createPane(tabId);
const fresh = createPane();
const leading = edge === "left" || edge === "top";
const root = createSplitNode(
edge === "left" || edge === "right" ? "row" : "column",
leading ? [fresh, own] : [own, fresh],
);
return openSplitTab(createSplitState(root, fresh.id));
}
function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) {
commitSplit(splitTabId, (state) => {
const result = splitPane(state, paneId, edge);
if (!result) warnSplitFull();
return result ? result.state : state;
});
setActiveTabId(splitTabId);
}
/**
* Splits a tab: a split grows at its focused pane, a tab inside a split
* grows at its own pane, anything else becomes a new split.
*/
function splitTabAt(tabId: string, edge: PaneEdge) {
const current = tabsRef.current;
const tab = current.find((t) => t.id === tabId);
if (!tab) return;
if (isSplitTab(tab)) {
splitPaneOf(tab.id, tab.split.focusedPaneId, edge);
return;
}
const parent = splitTabOf(current, tabId);
const pane = parent ? findPaneByTab(parent.split, tabId) : undefined;
if (parent && pane) {
splitPaneOf(parent.id, pane.id, edge);
return;
}
startSplit(canJoinSplit(tab) ? tab.id : null, edge);
}
function applyLayoutPreset(presetId: SplitPresetId) {
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (isSplitTab(active)) {
commitSplit(active.id, (state) => applyPreset(state, presetId));
return;
}
const root = buildPreset(
presetId,
active && canJoinSplit(active) ? [active.id] : [],
);
openSplitTab(createSplitState(root));
}
/** Closes a split tab, sending its tabs back to the tab bar. */
function unsplit(splitTabId: string) {
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
if (!isSplitTab(split)) return;
const focusedTabId =
findPane(split.split, split.split.focusedPaneId)?.tabId ??
listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ??
null;
setTabs((prev) => closeSplitTab(prev, splitTabId));
if (activeTabIdRef.current === splitTabId) {
const fallback = tabsRef.current.filter(
(tab) => tab.id !== splitTabId && !tab.parentSplitTabId,
);
setActiveTabId(
focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard",
);
}
}
/** Removes a pane; its tab, if any, goes back to the tab bar. */
function closePaneOf(splitTabId: string, paneId: string) {
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
if (!isSplitTab(split)) return;
if (listPanes(split.split.root).length <= 1) {
unsplit(splitTabId);
return;
}
editSplit(splitTabId, (state) => removePane(state, paneId));
}
function handleTabSplitAction(action: TabSplitAction) {
switch (action.kind) {
case "split":
splitTabAt(action.tabId, action.edge);
break;
case "splitActive":
splitTabAt(activeTabIdRef.current, action.edge);
break;
case "preset":
applyLayoutPreset(action.presetId);
break;
case "unsplit":
unsplit(action.splitTabId);
break;
case "addToPane":
setTabs((prev) =>
placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId),
);
setActiveTabId(action.splitTabId);
break;
case "addPane":
commitSplit(action.splitTabId, (state) => {
const result = addPaneAtEdge(state, action.edge, action.tabId);
if (!result) warnSplitFull();
return result ? result.state : state;
});
setActiveTabId(action.splitTabId);
break;
}
}
/** "Open in split" from the host list: aim the next tab, then open it. */
const splitOpenerRef = useRef<
(target: SplitOpenTarget, open: () => void) => void
>(() => {});
splitOpenerRef.current = (target, open) => {
if (target.kind === "pane") {
targetPane(target.splitTabId, target.paneId);
} else if (target.kind === "newPane") {
const split = tabsRef.current.find((tab) => tab.id === target.splitTabId);
if (!isSplitTab(split)) return;
const result = addPaneAtEdge(split.split, "right");
if (!result) {
warnSplitFull();
return;
}
editSplit(split.id, (state) =>
state === split.split ? result.state : state,
);
setActiveTabId(split.id);
targetPane(split.id, result.paneId, true);
} else {
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (!active || !canJoinSplit(active)) return;
const splitTab = startSplit(active.id, "right");
targetPane(splitTab.id, splitTab.split.focusedPaneId, true);
}
open();
};
/** A tab or pane dropped on the main area. */
const splitDropRef = useRef<
(source: SplitDragSource, hover: SplitDropHover) => void
>(() => {});
splitDropRef.current = (source, hover) => {
if (source.kind === "pane") {
const toPaneId = hover.paneId;
if (!toPaneId) return;
editSplit(source.splitTabId, (state) =>
movePane(state, source.paneId, toPaneId, hover.target),
);
return;
}
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (hover.paneId === null) {
if (hover.target === "center") return;
const other =
active && canJoinSplit(active) && active.id !== source.tabId
? active.id
: null;
const dragged = createPane(source.tabId);
const rest = createPane(other);
const leading = hover.target === "left" || hover.target === "top";
const root = createSplitNode(
hover.target === "left" || hover.target === "right" ? "row" : "column",
leading ? [dragged, rest] : [rest, dragged],
);
openSplitTab(createSplitState(root, dragged.id));
return;
}
if (!isSplitTab(active)) return;
const paneId = hover.paneId;
if (hover.target === "center") {
setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId));
return;
}
const edge = hover.target;
commitSplit(active.id, (state) => {
const result = splitPane(state, paneId, edge, source.tabId);
if (!result) warnSplitFull();
return result ? result.state : state;
});
};
splitActionsRef.current = {
splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge),
navigate: (direction) => {
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (!isSplitTab(active)) return false;
const view = document.querySelector(`[data-split-view="${active.id}"]`);
if (!view) return false;
const rects: Record<string, PaneRect> = {};
view
.querySelectorAll<HTMLElement>("[data-split-pane-id]")
.forEach((el) => {
const id = el.dataset.splitPaneId;
if (id) rects[id] = el.getBoundingClientRect();
});
const next = neighborPane(active.split.focusedPaneId, direction, rects);
if (next) editSplit(active.id, (state) => focusPane(state, next));
return true;
},
zoomFocused: () => {
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (!isSplitTab(active) || listPanes(active.split.root).length < 2) {
return false;
}
editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId));
return true;
},
closeFocusedPane: () => {
const active = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current,
);
if (isSplitTab(active)) {
closePaneOf(active.id, active.split.focusedPaneId);
}
},
};
useEffect(() => {
const disposeOpener = setSplitOpener((target, open) =>
splitOpenerRef.current(target, open),
);
const disposeDrop = setSplitDropHandler((source, hover) =>
splitDropRef.current(source, hover),
);
const activeIsSplit = () =>
isSplitTab(
tabsRef.current.find((tab) => tab.id === activeTabIdRef.current),
);
const entries = [
registerPaletteEntry({
id: "core.reconnectDisconnected",
titleKey: "nav.reconnectDisconnectedTerminals",
icon: RotateCcw,
keywords: ["reconnect", "disconnected", "ssh", "all", "network"],
scope: "global",
run: () => reconnectAllRef.current(),
}),
registerPaletteEntry({
id: "core.split.right",
titleKey: "splitScreen.splitRight",
icon: Columns2,
keywords: ["split", "pane"],
scope: "global",
run: () => splitActionsRef.current?.splitActive("right"),
}),
registerPaletteEntry({
id: "core.split.down",
titleKey: "splitScreen.splitDown",
icon: Rows2,
keywords: ["split", "pane"],
scope: "global",
run: () => splitActionsRef.current?.splitActive("bottom"),
}),
registerPaletteEntry({
id: "core.split.zoom",
titleKey: "splitScreen.zoom",
icon: Maximize2,
keywords: ["split", "pane"],
scope: "global",
when: activeIsSplit,
run: () => void splitActionsRef.current?.zoomFocused(),
}),
registerPaletteEntry({
id: "core.split.closePane",
titleKey: "splitScreen.closePane",
icon: X,
keywords: ["split", "pane"],
scope: "global",
when: activeIsSplit,
run: () => splitActionsRef.current?.closeFocusedPane(),
}),
];
return () => {
disposeOpener();
disposeDrop();
for (const dispose of entries) dispose();
};
}, []);
2026-05-11 01:23:11 -05:00
// ─── Rail / sidebar ──────────────────────────────────────────────────────
+5
2026-08-19 14:12:06 -05:00
// 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;
}
2026-10-01 16:20:16 -05:00
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
+5
2026-08-19 14:12:06 -05:00
if (fallback) setRightRailView(fallback as RailView);
}
2026-05-11 01:23:11 -05:00
function handleRailClick(view: RailView) {
if (railView === view && sidebarOpen) {
setSidebarOpen(false);
} else {
+5
2026-08-19 14:12:06 -05:00
// A panel lives in one dock at a time, so the left dock reclaims it.
if (rightRailView === view) setRightRailView(null);
2026-05-28 22:29:20 -05:00
if (view !== railView) setSidebarEditing(false);
+3
2026-08-06 14:41:39 -05:00
if (view !== railView) setSettingsFullscreen(false);
2026-05-11 01:23:11 -05:00
setRailView(view);
setSidebarOpen(true);
}
}
function editHostInManager(host: Host) {
setSidebarOpen(true);
setRailView("hosts");
2026-05-28 22:29:20 -05:00
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("host-manager:edit-host", { detail: host.id }),
);
}, 0);
2026-05-11 01:23:11 -05:00
}
const onSidebarMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setSidebarDragging(true);
const startX = e.clientX;
const startW = sidebarWidth;
2026-10-01 16:20:16 -05:00
// Widths are kept in Normal-size pixels, so a drag is scaled back.
const scale = remScale();
// One width update per frame, not per mousemove.
let frame = 0;
let clientX = startX;
2026-05-11 01:23:11 -05:00
function onMove(ev: MouseEvent) {
2026-10-01 16:20:16 -05:00
clientX = ev.clientX;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setSidebarWidth(
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
);
});
2026-05-11 01:23:11 -05:00
}
function onUp() {
2026-10-01 16:20:16 -05:00
cancelAnimationFrame(frame);
setSidebarWidth(
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
);
2026-05-11 01:23:11 -05:00
setSidebarDragging(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[sidebarWidth],
);
+5
2026-08-19 14:12:06 -05:00
// 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;
2026-10-01 16:20:16 -05:00
const scale = remScale();
let frame = 0;
let clientX = startX;
+5
2026-08-19 14:12:06 -05:00
function onMove(ev: MouseEvent) {
2026-10-01 16:20:16 -05:00
clientX = ev.clientX;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setRightSidebarWidth(
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
);
});
+5
2026-08-19 14:12:06 -05:00
}
function onUp() {
2026-10-01 16:20:16 -05:00
cancelAnimationFrame(frame);
setRightSidebarWidth(
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
);
+5
2026-08-19 14:12:06 -05:00
setRightSidebarDragging(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[rightSidebarWidth],
);
2026-05-28 22:29:20 -05:00
// 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;
2026-06-04 15:16:53 -04:00
const ref = tab.terminalRef.current;
2026-05-28 22:29:20 -05:00
ref?.fit?.();
ref?.notifyResize?.();
});
});
return id;
}, [tabs]);
2026-10-01 16:20:16 -05:00
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const activeSplit = isSplitTab(activeTab) ? activeTab : null;
// The pane each on-screen tab sits in, for the active split only.
const paneIdByTabId = new Map<string, string>();
if (activeSplit) {
for (const pane of shownPanes(activeSplit.split, isMobile)) {
if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id);
}
}
// Changes when panes appear, move or change tab, but not on a divider drag,
// which fits the terminals itself once it ends.
const splitShapeKey = activeSplit
? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[
...paneIdByTabId.entries(),
].join(",")}|${listPanes(activeSplit.split.root).length}`
: "";
2026-05-28 22:29:20 -05:00
useEffect(() => {
2026-10-01 16:20:16 -05:00
// Refitting every terminal on each drag frame is what made sidebar drags
// stutter; fit once when the drag ends instead.
if (sidebarDragging || rightSidebarDragging) return;
2026-05-28 22:29:20 -05:00
const id = resizeAllTerminals();
return () => cancelAnimationFrame(id);
2026-10-01 16:20:16 -05:00
}, [
splitShapeKey,
sidebarWidth,
sidebarOpen,
rightSidebarWidth,
rightRailView,
sidebarDragging,
rightSidebarDragging,
]);
2026-05-28 22:29:20 -05:00
2026-10-01 16:20:16 -05:00
// A tab inside a split is never shown on its own: activating one (tab jump,
// the connections panel, an existing session) shows its split instead,
// focused on its pane. Also falls back when the active tab is gone.
useLayoutEffect(() => {
if (!activeTab) {
if (!tabsReady) return;
const topLevel = tabs.filter((tab) => !tab.parentSplitTabId);
setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard");
return;
}
if (!activeTab.parentSplitTabId) return;
const parent = splitTabOf(tabs, activeTab.id);
if (!parent) return;
const childId = activeTab.id;
setTabs((prev) =>
updateSplit(prev, parent.id, (state) => {
const pane = findPaneByTab(state, childId);
return pane ? focusPane(state, pane.id) : state;
}),
);
setActiveTabId(parent.id);
}, [activeTab, tabs, tabsReady]);
// Moving focus to a pane moves keyboard focus into its session, unless the
// user is in a menu or already typing in that pane.
const focusedPaneId = activeSplit?.split.focusedPaneId ?? null;
const focusedPaneTabId = focusedPaneId
? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null)
: null;
useEffect(() => {
if (!focusedPaneId || !focusedPaneTabId) return;
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const attempt = () => {
const focused = document.activeElement as HTMLElement | null;
const paneEl = paneElsRef.current.get(focusedPaneId);
if (focused && paneEl?.contains(focused)) return;
if (
focused?.closest?.(
'[data-split-pane-header], [role="menu"], [role="dialog"]',
)
) {
return;
}
const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId);
const handle = tab?.terminalRef?.current as TabHandle | null | undefined;
if (handle?.focus) {
handle.focus();
return;
}
// A session opened into the pane a moment ago may not have mounted yet.
if (attempts++ < 10) timer = setTimeout(attempt, 100);
};
const frame = requestAnimationFrame(attempt);
return () => {
cancelAnimationFrame(frame);
if (timer) clearTimeout(timer);
};
}, [focusedPaneId, focusedPaneTabId]);
useEffect(() => {
publishSplitTargets({
splits: summarizeSplits(tabs, MAX_PANES),
canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId,
});
}, [tabs, activeTab]);
2026-05-28 22:29:20 -05:00
// 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.
+17
2026-09-20 14:56:39 -05:00
// 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(() => {
2026-05-28 22:29:20 -05:00
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) {
2026-10-01 16:20:16 -05:00
const isTerminal = !!getTabType(tab.type)?.ownBackground;
2026-05-28 22:29:20 -05:00
const node = getTabNode(tab.id, isTerminal);
2026-10-01 16:20:16 -05:00
const paneId = paneIdByTabId.get(tab.id);
const paneEl = paneId ? paneElsRef.current.get(paneId) : undefined;
const inPane = !!paneId;
2026-05-28 22:29:20 -05:00
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 = "";
+17
2026-09-20 14:56:39 -05:00
node.style.contentVisibility = "";
2026-05-28 22:29:20 -05:00
} 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";
2026-06-04 15:16:53 -04:00
node.style.zIndex = activeInline ? "1" : "0";
+17
2026-09-20 14:56:39 -05:00
// 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";
2026-05-28 22:29:20 -05:00
} else {
+17
2026-09-20 14:56:39 -05:00
// 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;
}
}
2026-05-28 22:29:20 -05:00
node.style.visibility = "";
node.style.pointerEvents = "";
node.style.zIndex = activeInline ? "2" : "";
node.style.display = activeInline ? "" : "none";
}
}
}
});
2026-10-01 16:20:16 -05:00
const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget);
+5
2026-08-19 14:12:06 -05:00
const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId);
2026-05-11 01:23:11 -05:00
+5
2026-08-19 14:12:06 -05:00
function reorderTopLevelTabs(reordered: Tab[]) {
setTabs((prev) => [
...reordered,
...prev.filter((tab) => tab.parentSplitTabId),
]);
}
2026-10-01 16:20:16 -05:00
// What command-target panels act on. Falls back to the remembered
+5
2026-08-19 14:12:06 -05:00
// terminal when the active tab isn't one, and drops it once it's closed.
2026-10-01 16:20:16 -05:00
const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId)
? workingTabId
+5
2026-08-19 14:12:06 -05:00
: 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.
2026-10-01 16:20:16 -05:00
/**
* What plugins may ask of the shell. Rebuilt every render so it always
* closes over current state, and published to the plugin runtime.
*/
const shellCallbacksImpl: TabShellCallbacks = {
openTab: (host, type, options) => {
if (!host || getTabType(type)?.singleton) {
openSingletonTab(
type,
undefined,
host ?? undefined,
options?.data,
options?.label,
);
return;
}
openTab(host, type, undefined, options);
},
openSingletonTab: (type, options) =>
openSingletonTab(type, undefined, undefined, options?.data),
connectHost: (host, type) => connectHost(host, type as TabType),
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));
},
openHostEditor: (draft) => {
setSidebarOpen(true);
setRailView("hosts");
setTimeout(
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-host", { detail: draft }),
),
0,
);
},
saveQuickConnect: saveQuickConnectHost,
};
// Tabs get one stable bag that forwards to the latest callbacks, so a shell
// render does not re-render every open tab.
const shellImplRef = useRef(shellCallbacksImpl);
useLayoutEffect(() => {
shellImplRef.current = shellCallbacksImpl;
});
const shellCallbacks = useMemo<TabShellCallbacks>(
() => ({
openTab: (...args) => shellImplRef.current.openTab(...args),
openSingletonTab: (...args) =>
shellImplRef.current.openSingletonTab(...args),
connectHost: (...args) => shellImplRef.current.connectHost(...args),
closeTab: (...args) => shellImplRef.current.closeTab(...args),
renameTab: (...args) => shellImplRef.current.renameTab(...args),
openRailView: (...args) => shellImplRef.current.openRailView(...args),
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
openHostEditor: (...args) =>
shellImplRef.current.openHostEditor?.(...args),
saveQuickConnect: (...args) =>
shellImplRef.current.saveQuickConnect!(...args),
}),
[],
);
// Panels close the sidebar on mobile after opening something, the same as
// the hosts panel does.
const panelShell = useMemo<TabShellCallbacks>(
() => ({
...shellCallbacks,
openTab: (...args) => {
shellCallbacks.openTab(...args);
if (isMobile) setSidebarOpen(false);
},
}),
[shellCallbacks, isMobile],
);
useEffect(() => {
setShellCallbacks(shellCallbacks);
setShellLayoutProvider({
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
applyLayout: (layout, options) =>
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
});
});
useEffect(() => {
setShellHosts(allHosts, hostsLoaded);
}, [allHosts, hostsLoaded]);
+5
2026-08-19 14:12:06 -05:00
const renderSidebarPanels = (railView: RailView, owned = true) => (
+8
2026-07-19 12:29:52 -05:00
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
+5
2026-08-19 14:12:06 -05:00
{owned && (
<>
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
>
<HostsPanel
+17
2026-09-20 14:56:39 -05:00
onOpenTab={(host, type, options) => {
connectHost(host, type, options);
+5
2026-08-19 14:12:06 -05:00
if (isMobile) setSidebarOpen(false);
}}
onEditHost={editHostInManager}
hostTree={realHostTree ?? undefined}
loading={hostsLoading}
onEditingChange={setSidebarEditing}
active={railView === "hosts"}
/>
</div>
+8
2026-07-19 12:29:52 -05:00
+5
2026-08-19 14:12:06 -05:00
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
>
<CredentialsPanel
onEditingChange={setSidebarEditing}
active={railView === "credentials"}
/>
</div>
</>
)}
+8
2026-07-19 12:29:52 -05:00
{railView === "quick-connect" && (
<QuickConnectPanel
onConnect={(host, type) => {
openTab(host, type);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
2026-10-01 16:20:16 -05:00
{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 (
<div
key={panel.id}
className={`flex flex-col flex-1 min-h-0 overflow-y-auto ${shown ? "" : "hidden"}`}
>
<Panel
targetTab={terminalTabs.find(
(tab) => tab.id === targetTerminalTabId,
)}
active={shown}
shell={panelShell}
setEditing={setSidebarEditing}
activeTabType={
tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined
}
placement={owned ? "left" : "right"}
/>
</div>
);
})}
+8
2026-07-19 12:29:52 -05:00
2026-10-01 16:20:16 -05:00
{!isCoreRailView(railView) && !getPanel(railView) && (
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
+5
2026-08-19 14:12:06 -05:00
)}
+8
2026-07-19 12:29:52 -05:00
{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;
2026-10-01 16:20:16 -05:00
if (!host && !getTabType(record.tabType)?.hostless) return;
+8
2026-07-19 12:29:52 -05:00
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}
/>
</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 }))
2026-05-28 22:05:25 -04:00
}
+8
2026-07-19 12:29:52 -05:00
/>
</div>
)}
2026-05-11 01:23:11 -05:00
2026-10-01 16:20:16 -05:00
{railView === "sync" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SyncPanel />
</div>
)}
+5
2026-08-19 14:12:06 -05:00
{railView === "admin-settings" && showAdminUI && (
+8
2026-07-19 12:29:52 -05:00
<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>
)}
</div>
</Suspense>
2026-05-11 01:23:11 -05:00
);
2026-10-01 16:20:16 -05:00
function splitViewActions(splitTabId: string): SplitViewActions {
return {
focusPane: (paneId) =>
editSplit(splitTabId, (state) => focusPane(state, paneId)),
resize: (nodeId, sizes) =>
editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)),
equalize: (nodeId) =>
editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)),
splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge),
closePane: (paneId) => closePaneOf(splitTabId, paneId),
closeSession: (tabId) => closeTab(tabId),
moveToTab: (paneId) => closePaneOf(splitTabId, paneId),
showInPane: (paneId, tabId) => {
if (tabId) {
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
} else {
editSplit(splitTabId, (state) => assignTab(state, paneId, null));
}
},
swapPanes: (firstId, secondId) =>
editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)),
toggleZoom: (paneId) =>
editSplit(splitTabId, (state) => toggleZoom(state, paneId)),
resizeEnd: () => resizeAllTerminals(),
};
}
// Picking a host for a pane always starts a new session, so it never pulls
// a session out of the tab bar or another split.
const pickerShell: TabShellCallbacks = {
...shellCallbacks,
openTab: (host, type, options) =>
shellCallbacks.openTab(host, type, { ...options, forceNewTab: true }),
};
function renderEmptyPane(
splitTabId: string,
paneId: string,
paneIndex: number,
) {
return (
<EmptyPanePicker
paneIndex={paneIndex}
freeTabs={tabs.filter(
(tab) => !tab.parentSplitTabId && canJoinSplit(tab),
)}
hosts={allHosts}
newTabOptions={tabTypes
.filter((def) => def.multiInstance)
.map((def) => ({
type: def.id,
label: def.titleKey ? t(def.titleKey) : def.id,
icon: def.icon,
}))}
onPickTab={(tabId) =>
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId))
}
onPickHost={(host, pick: PickHostTarget) => {
targetPane(splitTabId, paneId);
if ("item" in pick) {
const item = pick.action
.items?.(host)
.find((candidate) => candidate.id === pick.item.id);
item?.run(host, pickerShell);
} else {
runHostAction(pick.action, host, pickerShell);
}
}}
onOpenType={(type) => {
targetPane(splitTabId, paneId);
openMultiInstanceTab(type);
}}
onClosePane={() => closePaneOf(splitTabId, paneId)}
/>
);
}
+5
2026-08-19 14:12:06 -05:00
const sidebarPanelContent = renderSidebarPanels(railView);
2026-05-11 01:23:11 -05:00
// Sidebar header — shared
const sidebarHeader = (
<div className="flex flex-row items-center border-b border-border h-12.5 shrink-0">
+5
2026-08-19 14:12:06 -05:00
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
{sidebarTitle(railView)}
2026-05-11 01:23:11 -05:00
</span>
2026-10-01 16:20:16 -05:00
{!isMobile && promotableIds().includes(railView) && (
+5
2026-08-19 14:12:06 -05:00
<>
<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>
</>
)}
2026-10-01 16:20:16 -05:00
{!isMobile && rightDockableIds().includes(railView) && (
+5
2026-08-19 14:12:06 -05:00
<>
<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>
</>
)}
2026-05-28 22:29:20 -05:00
{!isMobile && (
2026-05-11 01:23:11 -05:00
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
2026-05-28 22:29:20 -05:00
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
2026-10-01 16:20:16 -05:00
title={t("nav.resetSidebarWidth")}
+2
2026-06-16 15:59:53 -05:00
onClick={() => setSidebarWidth(291)}
2026-05-11 01:23:11 -05:00
>
+5
2026-08-19 14:12:06 -05:00
<RotateCcw className="size-3.5" />
2026-05-11 01:23:11 -05:00
</Button>
</>
)}
+3
2026-08-06 14:41:39 -05:00
{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>
</>
)}
2026-05-11 01:23:11 -05:00
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
2026-10-01 16:20:16 -05:00
title={t("nav.collapseSidebar")}
+3
2026-08-06 14:41:39 -05:00
onClick={() => {
setSettingsFullscreen(false);
setSidebarOpen(false);
}}
2026-05-11 01:23:11 -05:00
>
<ChevronLeft className="size-4" />
</Button>
</div>
);
+5
2026-08-19 14:12:06 -05:00
const sidebarHint = !isMobile && (
<MultiPanelHint
2026-10-01 16:20:16 -05:00
canPromote={promotableIds().includes(railView)}
canRightDock={rightDockableIds().includes(railView)}
+5
2026-08-19 14:12:06 -05:00
onOpenAsTab={() => openSingletonTab(railView as TabType)}
onOpenInRightDock={() => openInRightDock(railView)}
/>
);
2026-05-11 01:23:11 -05:00
return (
2026-06-29 13:28:26 -05:00
<ServerStatusProvider isAuthenticated={!!username}>
+2
2026-07-26 18:47:27 -05:00
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
+3
2026-08-06 14:41:39 -05:00
{!settingsFullscreen && (
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
username={username}
+5
2026-08-19 14:12:06 -05:00
isAdmin={showAdminUI}
2026-10-01 16:20:16 -05:00
pluginsSettled={pluginsSettled}
+3
2026-08-06 14:41:39 -05:00
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
+5
2026-08-19 14:12:06 -05:00
onOpenInRightDock={openInRightDock}
+3
2026-08-06 14:41:39 -05:00
onLogout={onLogout}
/>
)}
+2
2026-07-26 18:47:27 -05:00
{/* Desktop: inline resizable sidebar */}
{!isMobile && (
<div
+3
2026-08-06 14:41:39 -05:00
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"}` : ""}`}
+2
2026-07-26 18:47:27 -05:00
style={{
+3
2026-08-06 14:41:39 -05:00
width: settingsFullscreen
? "100vw"
: sidebarOpen
2026-10-01 16:20:16 -05:00
? rem(sidebarEditing ? 560 : sidebarWidth)
+3
2026-08-06 14:41:39 -05:00
: 0,
+2
2026-07-26 18:47:27 -05:00
transition: sidebarDragging ? "none" : "width 0.2s",
}}
>
{sidebarHeader}
+5
2026-08-19 14:12:06 -05:00
{sidebarHint}
+2
2026-07-26 18:47:27 -05:00
{sidebarPanelContent}
+3
2026-08-06 14:41:39 -05:00
{sidebarOpen && !sidebarEditing && !settingsFullscreen && (
+2
2026-07-26 18:47:27 -05:00
<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}
+3
2026-08-06 14:41:39 -05:00
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)]"}`}
+2
2026-07-26 18:47:27 -05:00
style={{ height: "100dvh" }}
>
{sidebarHeader}
{sidebarPanelContent}
</SheetContent>
</Sheet>
)}
{/* Main content area */}
<div
+3
2026-08-06 14:41:39 -05:00
inert={settingsFullscreen ? true : undefined}
aria-hidden={settingsFullscreen || undefined}
+17
2026-09-20 14:56:39 -05:00
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
+2
2026-07-26 18:47:27 -05:00
>
{!isMobile && !sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
2026-10-01 16:20:16 -05:00
title={t("nav.openSidebar")}
+2
2026-07-26 18:47:27 -05:00
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
+5
2026-08-19 14:12:06 -05:00
tabs={topLevelTabs}
+2
2026-07-26 18:47:27 -05:00
activeTabId={activeTabId}
2026-10-01 16:20:16 -05:00
splits={summarizeSplits(tabs, MAX_PANES)}
activeSplitFull={
activeSplit ? !canAddPane(activeSplit.split) : false
}
+2
2026-07-26 18:47:27 -05:00
onSetActiveTab={setActiveTabId}
onCloseTab={closeTab}
onRefreshTab={refreshTab}
2026-10-01 16:20:16 -05:00
onReconnectDisconnected={reconnectAllDisconnected}
+5
2026-08-19 14:12:06 -05:00
onReorderTabs={reorderTopLevelTabs}
2026-10-01 16:20:16 -05:00
onSplitAction={handleTabSplitAction}
+2
2026-07-26 18:47:27 -05:00
onRenameTab={renameTab}
isAppFullscreen={isAppFullscreen}
onToggleAppFullscreen={toggleAppFullscreen}
+5
2026-08-19 14:12:06 -05:00
rightDockOpen={rightRailView !== null}
onToggleRightDock={isMobile ? undefined : toggleRightDock}
2026-10-01 16:20:16 -05:00
showTabNumbers={showTabNumbers}
+2
2026-07-26 18:47:27 -05:00
/>
2026-10-01 16:20:16 -05:00
<div
ref={mainAreaRef}
className="relative flex flex-col flex-1 min-h-0 overflow-hidden"
>
{activeSplit && (
<div className="motion-workspace-layout absolute inset-0 flex flex-col">
+2
2026-07-26 18:47:27 -05:00
<SplitView
2026-10-01 16:20:16 -05:00
splitTab={activeSplit}
+2
2026-07-26 18:47:27 -05:00
tabs={tabs}
2026-10-01 16:20:16 -05:00
isMobile={isMobile}
actions={splitViewActions(activeSplit.id)}
+2
2026-07-26 18:47:27 -05:00
onPaneContentRef={onPaneContentRef}
2026-10-01 16:20:16 -05:00
renderEmptyPane={(paneId, paneIndex) =>
renderEmptyPane(activeSplit.id, paneId, paneIndex)
}
+2
2026-07-26 18:47:27 -05:00
/>
</div>
)}
2026-10-01 16:20:16 -05:00
<SplitDropOverlay containerRef={mainAreaRef} />
+2
2026-07-26 18:47:27 -05:00
{/* 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.
2026-10-01 16:20:16 -05:00
Hidden while a split is active. */}
+2
2026-07-26 18:47:27 -05:00
<div
ref={normalViewRef}
className="absolute inset-0"
2026-10-01 16:20:16 -05:00
style={{ display: activeSplit ? "none" : undefined }}
+2
2026-07-26 18:47:27 -05:00
>
+17
2026-09-20 14:56:39 -05:00
{tabsByPortalOrder.map((tab) => {
+5
2026-08-19 14:12:06 -05:00
const tabNode = getTabNode(
tab.id,
2026-10-01 16:20:16 -05:00
!!getTabType(tab.type)?.ownBackground,
+5
2026-08-19 14:12:06 -05:00
);
2026-10-01 16:20:16 -05:00
const paneId = paneIdByTabId.get(tab.id);
const inPane = !!paneId;
+2
2026-07-26 18:47:27 -05:00
const activeInline = !inPane && tab.id === activeTabId;
+5
2026-08-19 14:12:06 -05:00
const isFocusedPane = inPane
2026-10-01 16:20:16 -05:00
? paneId === focusedPaneId
+5
2026-08-19 14:12:06 -05:00
: activeInline;
+2
2026-07-26 18:47:27 -05:00
return createPortal(
2026-10-01 16:20:16 -05:00
renderTabContent(tab, {
shell: shellCallbacks,
panelTargetTab: terminalTabs.find(
(t) => t.id === targetTerminalTabId,
),
isVisible: inPane || activeInline,
+5
2026-08-19 14:12:06 -05:00
isFocusedPane,
2026-10-01 16:20:16 -05:00
inSplit: inPane,
}),
+2
2026-07-26 18:47:27 -05:00
tabNode,
tab.id,
);
})}
</div>
</div>
</div>
{/* Bottom nav bar — mobile only */}
<MobileBottomBar
railView={railView}
sidebarOpen={sidebarOpen}
onRailClick={handleRailClick}
/>
</div>
+5
2026-08-19 14:12:06 -05:00
{/* 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={{
2026-10-01 16:20:16 -05:00
width: rem(rightSidebarWidth),
+5
2026-08-19 14:12:06 -05:00
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>
)}
2026-05-11 01:23:11 -05:00
</div>
</div>
2026-10-01 16:20:16 -05:00
<ComponentSlot slotId="shell.overlay" />
+8
2026-07-19 12:29:52 -05:00
{commandPaletteOpen && (
<Suspense fallback={null}>
<CommandPalette
isOpen={commandPaletteOpen}
setIsOpen={setCommandPaletteOpen}
hosts={allHosts}
+5
2026-08-19 14:12:06 -05:00
terminalTabs={terminalTabs}
activeTabId={activeTabId}
onOpenPanel={(view) => handleRailClick(view as RailView)}
+8
2026-07-19 12:29:52 -05:00
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
].includes(type)
) {
openSingletonTab(type, pendingEvent);
2026-10-01 16:20:16 -05:00
} else if (getTabType(type)?.multiInstance) {
openMultiInstanceTab(type);
} else if (getTabType(type)?.singleton) {
// A singleton plugin tab, optionally preselecting a host.
+8
2026-07-19 12:29:52 -05:00
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>
)}
+5
2026-08-19 14:12:06 -05:00
<OnboardingDialog
open={showOnboarding}
2026-10-01 16:20:16 -05:00
context={{}}
+5
2026-08-19 14:12:06 -05:00
onClose={() => setShowOnboarding(false)}
/>
+8
2026-07-19 12:29:52 -05:00
<DonationReminderModal
+5
2026-08-19 14:12:06 -05:00
open={showDonationModal && !showOnboarding}
+8
2026-07-19 12:29:52 -05:00
onDismiss={handleDismissDonationModal}
/>
2026-06-29 13:28:26 -05:00
</ServerStatusProvider>
2026-05-11 01:23:11 -05:00
);
}