/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import type { TabHandle } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Separator } from "@/components/separator";
import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet";
import {
ChevronLeft,
ChevronRight,
Maximize2,
Minimize2,
PanelRight,
RotateCcw,
SquareArrowOutUpRight,
} from "lucide-react";
import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
createRef,
lazy,
Suspense,
} from "react";
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { resetPermissionsCache } from "@/hooks/use-permissions";
import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
isCoreRailView,
railItemLabel,
promotableIds,
rightDockableIds,
useRailItems,
} from "@/sidebar/rail-items";
import { MultiPanelHint } from "@/sidebar/MultiPanelHint";
import { OnboardingDialog } from "@/onboarding/OnboardingDialog";
import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView";
import { renderTabContent } from "@/shell/tabUtils";
import { TabBar } from "@/shell/TabBar";
import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts";
import { parseCustomKeybindings } from "@/api/open-tabs-api";
import { findMatchingKeybinding } from "@/lib/keybinding-match";
import type {
CustomKeybinding,
KeybindingActionType,
} from "@/types/keybindings";
// Shell surfaces that are not needed for first paint.
const CommandPalette = lazy(() =>
import("@/shell/CommandPalette").then((m) => ({
default: m.CommandPalette,
})),
);
const HostsPanel = lazy(() =>
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
);
const QuickConnectPanel = lazy(() =>
import("@/sidebar/QuickConnectPanel").then((m) => ({
default: m.QuickConnectPanel,
})),
);
const SplitScreenPanel = lazy(() =>
import("@/sidebar/SplitScreenPanel").then((m) => ({
default: m.SplitScreenPanel,
})),
);
const AlertManager = lazy(() =>
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
default: m.AlertManager,
})),
);
// Secondary rail panels — load on first open, not with the shell critical path.
const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
// One screen behind both rail entries. It lazy-loads the profile and admin
// panels itself, so the shell no longer imports either directly.
const SettingsScreen = lazy(() =>
import("@/settings/SettingsScreen").then((m) => ({
default: m.SettingsScreen,
})),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
);
const ConnectionsPanel = lazy(() =>
import("@/sidebar/ConnectionsPanel").then((m) => ({
default: m.ConnectionsPanel,
})),
);
function SidebarPanelFallback() {
return (
);
}
import type {
Tab,
TabType,
Host,
SplitMode,
HostFolder,
ThemeId,
FontSizeId,
WorkspacePayload,
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
import { useTheme } from "@/components/theme-provider";
import {
getSSHHosts,
getSSHFolders,
getUserInfo,
getOpenTabs,
addOpenTab,
deleteOpenTab,
patchOpenTab,
createSSHHost,
getActiveSessions,
getUserPreferences,
saveUserPreferences,
dismissDonationModal,
isElectron,
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
import {
buildLayoutPayload,
remapSlotIds,
resolveLayoutTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
import { buildHostTree } from "@/sidebar/build-host-tree";
import {
assignTabsToSplit,
createSplitConfig,
releaseSplitTabs,
restoreSplitTabs,
serializeSplitTabs,
type PersistedSplitTab,
} from "@/shell/splitTabUtils";
import {
canRestoreTabType,
getTabType,
isPersistentTabType,
isSessionTabType,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { invokeAction } from "@/shell/action-registry";
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
notifyShellReady,
notifyTabsChanged,
resetShellBridge,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
} from "@/plugin-host/shell-bridge";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ────────────────────────────────────────────────────────────────
export function AppShell({
username,
onLogout,
}: {
username: string;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
const registeredPanels = usePanels();
const { settled: pluginsSettled } = usePluginStore();
const [tabs, setTabs] = useState([
{
id: "dashboard",
instanceId: "dashboard",
type: "dashboard",
label: t("nav.dashboard"),
openedAt: Date.now(),
},
]);
const [activeTabId, setActiveTabId] = useState("dashboard");
const [userPrefs, setUserPrefs] = useState({
reopenTabsOnLogin: false,
});
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
const [hostsLoaded, setHostsLoaded] = useState(false);
// Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this
const [tabsReady, setTabsReady] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [splitMode, setSplitMode] = useState("none");
// paneTabIds holds live tab.id values, which change on every restore, so we
// can't restore it from storage directly. It starts empty and gets filled in
// once by the reconciliation effect below, keyed off the stable instanceId
// values saved in termix_paneInstanceIds.
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
Array(6).fill(null),
);
const paneLayoutRestoredRef = useRef(false);
const splitTabsRestoredRef = useRef(false);
useEffect(() => {
paneTabIdsRef.current = paneTabIds;
}, [paneTabIds]);
const [rowSizes, setRowSizes] = useState(
() => defaultSizes("none").rowSizes,
);
const [rowColSizes, setRowColSizes] = useState(
() => defaultSizes("none").rowColSizes,
);
const changeSplitMode = useCallback((mode: SplitMode) => {
setSplitMode(mode);
const d = defaultSizes(mode);
setRowSizes(d.rowSizes);
setRowColSizes(d.rowColSizes);
}, []);
const [focusedPaneIndex, setFocusedPaneIndex] = useState(null);
const [realHostTree, setRealHostTree] = useState(null);
const [hostsLoading, setHostsLoading] = useState(true);
const [allHosts, setAllHosts] = useState([]);
const allHostsRef = useRef(allHosts);
allHostsRef.current = allHosts;
const [isAdmin, setIsAdmin] = useState(false);
// The standalone desktop backend still owns system settings such as the
// Tailscale API key, even though it has only one implicit user.
const showAdminUI = isAdmin;
const [userId, setUserId] = useState(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
OpenTabRecord[]
>([]);
// First-run onboarding. The backend hands accounts that predate this feature
// an already-completed state, so only genuinely new users are interrupted.
const uiPrefs = useUiPreferencesContext();
const onboardingPending =
!!uiPrefs?.loaded &&
uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION;
/**
* Both onboarding entry points wait for plugins first, since plugins add
* steps of their own (the AI step, feature cards).
*/
const loadOnboardingContext = useCallback(async () => {
const { settledPromise } = await import("@/plugin-host/plugin-store");
await settledPromise();
}, []);
useEffect(() => {
if (!username || !onboardingPending) return;
let cancelled = false;
loadOnboardingContext().finally(() => {
if (!cancelled) setShowOnboarding(true);
});
return () => {
cancelled = true;
};
}, [username, onboardingPending, loadOnboardingContext]);
// "Run setup again" from settings.
useEffect(() => {
const handler = () => {
loadOnboardingContext().finally(() => setShowOnboarding(true));
};
window.addEventListener("termix:open-onboarding", handler);
return () => window.removeEventListener("termix:open-onboarding", handler);
}, [loadOnboardingContext]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState("hosts");
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
string | undefined
>(undefined);
const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] =
useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291;
});
const [sidebarDragging, setSidebarDragging] = useState(false);
const [sidebarEditing, setSidebarEditing] = useState(false);
const [settingsFullscreen, setSettingsFullscreen] = useState(false);
// Right dock — a second panel column so reference panels like history can
// stay visible while the left sidebar is used for something else.
const [rightRailView, setRightRailView] = useState(() => {
const saved = localStorage.getItem("termix_rightRailView");
return saved && rightDockableIds().includes(saved)
? (saved as RailView)
: null;
});
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_rightSidebarWidth");
return saved ? parseInt(saved, 10) : 291;
});
const [rightSidebarDragging, setRightSidebarDragging] = useState(false);
// Remembers the last panel shown in the dock so the tab bar toggle can bring
// it back instead of always falling back to the same default.
const lastRightRailViewRef = useRef(
localStorage.getItem("termix_lastRightRailView"),
);
const [isAppFullscreen, setIsAppFullscreen] = useState(
() => !!document.fullscreenElement,
);
useEffect(() => {
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
}, [sidebarWidth]);
useEffect(() => {
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
}, [rightSidebarWidth]);
useEffect(() => {
if (rightRailView) {
localStorage.setItem("termix_rightRailView", rightRailView);
lastRightRailViewRef.current = rightRailView;
localStorage.setItem("termix_lastRightRailView", rightRailView);
} else {
localStorage.removeItem("termix_rightRailView");
}
}, [rightRailView]);
useEffect(() => {
if (!splitTabsRestoredRef.current) return;
localStorage.setItem(
"termix_splitTabs",
JSON.stringify(serializeSplitTabs(tabs)),
);
}, [tabs]);
const isMobile = useIsMobile();
const isSettingsView =
railView === "user-profile" || railView === "admin-settings";
useEffect(() => {
if (!settingsFullscreen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsFullscreen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [settingsFullscreen]);
useEffect(() => {
if (!isSettingsView) setSettingsFullscreen(false);
}, [isSettingsView]);
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
useEffect(() => {
if (isMobile) {
sidebarOpenBeforeMobile.current = sidebarOpen;
setSidebarOpen(false);
} else {
setSidebarOpen(sidebarOpenBeforeMobile.current);
}
}, [isMobile]);
useEffect(() => {
getUserInfo()
.then((info) => {
setIsAdmin(info.is_admin);
setUserId(info.userId);
setShowDonationModal(!!info.show_donation_modal);
})
.catch(() => setIsAdmin(false));
}, []);
// Plugin frontends register the tabs, panels and actions that a saved
// session may need, so they load before tabs are restored. On logout every
// plugin is deactivated, since the next user may see different ones.
useEffect(() => {
startPluginRuntime().catch(() => {});
return () => {
void stopPluginRuntime();
resetShellBridge();
};
}, []);
const handleDismissDonationModal = useCallback(() => {
setShowDonationModal(false);
dismissDonationModal().catch(() => {});
}, []);
const toggleAppFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
if (!document.fullscreenEnabled) {
toast.error("Fullscreen is not supported by this browser");
return;
}
await document.documentElement.requestFullscreen();
} catch {
toast.error("Unable to toggle fullscreen mode");
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
setIsAppFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const lastShiftTime = useRef(0);
const tabsRef = useRef(tabs);
const activeTabIdRef = useRef(activeTabId);
const closeActiveTabRef = useRef<() => void>(() => {});
const globalKeybindingsRef = useRef([]);
const splitModeRef = useRef(splitMode);
const focusedPaneIndexRef = useRef(null);
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
Array(6).fill(null),
);
const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null));
useEffect(() => {
tabsRef.current = tabs;
}, [tabs]);
useEffect(() => {
let cancelled = false;
const load = () => {
getUserPreferences()
.then((prefs) => {
if (cancelled) return;
globalKeybindingsRef.current = parseCustomKeybindings(
prefs.customKeybindings,
).filter(
(binding) =>
binding.enabled &&
[
"nextTab",
"previousTab",
"openCommandPalette",
"reconnectSession",
].includes(binding.action.type),
);
})
.catch(() => {});
};
load();
window.addEventListener("customKeybindingsChanged", load);
return () => {
cancelled = true;
window.removeEventListener("customKeybindingsChanged", load);
};
}, []);
useEffect(() => {
const runAction = (type: KeybindingActionType) => {
if (type === "openCommandPalette") {
setCommandPaletteOpen(true);
return;
}
if (type === "reconnectSession") {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const termRef = terminalRefs.current.get(tabId);
(termRef?.current as TabHandle | null)?.reconnect?.();
return;
}
const currentTabs = tabsRef.current;
if (currentTabs.length < 2) return;
const index = currentTabs.findIndex(
(tab) => tab.id === activeTabIdRef.current,
);
const offset = type === "nextTab" ? 1 : -1;
const next = (index + offset + currentTabs.length) % currentTabs.length;
setActiveTabId(currentTabs[next].id);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.target instanceof Element &&
event.target.closest("[data-keybinding-recorder]")
)
return;
const binding = findMatchingKeybinding(
event,
globalKeybindingsRef.current,
);
if (!binding) return;
event.preventDefault();
event.stopPropagation();
runAction(binding.action.type);
};
const handleAction = (event: Event) =>
runAction(
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("termix:global-keybinding", handleAction);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("termix:global-keybinding", handleAction);
};
}, []);
useEffect(() => {
activeTabIdRef.current = activeTabId;
}, [activeTabId]);
useEffect(() => {
return window.electronAPI?.onCloseActiveTab?.(() => {
if (dispatchCtrlW(document.activeElement)) return;
closeActiveTabRef.current();
});
}, []);
const skipSplitSyncRef = useRef(false);
const activeTabAvailable = tabs.some((tab) => tab.id === activeTabId);
useEffect(() => {
const active = tabs.find((tab) => tab.id === activeTabId);
const config = active?.type === "split-screen" ? active.splitConfig : null;
skipSplitSyncRef.current = true;
if (!config) {
setSplitMode("none");
setPaneTabIds(Array(6).fill(null));
setFocusedPaneIndex(null);
return;
}
setSplitMode(config.mode);
setPaneTabIds(config.paneTabIds);
setRowSizes(config.rowSizes);
setRowColSizes(config.rowColSizes);
setFocusedPaneIndex(0);
}, [activeTabId, activeTabAvailable]);
useEffect(() => {
if (skipSplitSyncRef.current) {
skipSplitSyncRef.current = false;
return;
}
if (splitMode === "none") return;
setTabs((prev) => {
const active = prev.find((tab) => tab.id === activeTabId);
if (active?.type !== "split-screen") return prev;
const config = createSplitConfig(splitMode, paneTabIds, {
rowSizes,
rowColSizes,
});
const updated = prev.map((tab) =>
tab.id === activeTabId ? { ...tab, splitConfig: config } : tab,
);
return assignTabsToSplit(updated, activeTabId, paneTabIds);
});
}, [activeTabId, paneTabIds, rowColSizes, rowSizes, splitMode]);
// Panels like history and snippets act on "the terminal you're working in".
// Once those panels can themselves be the active tab, activeTabId points at
// the panel and the lookup misses, so remember the last terminal instead.
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
useEffect(() => {
const active = tabs.find((t) => t.id === activeTabId);
if (active && getTabType(active.type)?.commandTarget) {
setLastTerminalTabId(active.id);
}
}, [activeTabId, tabs]);
useEffect(() => {
splitModeRef.current = splitMode;
}, [splitMode]);
useEffect(() => {
focusedPaneIndexRef.current = focusedPaneIndex;
}, [focusedPaneIndex]);
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [showTabNumbers, setShowTabNumbers] = useState(
() => localStorage.getItem("showTabNumbers") === "true",
);
const terminalRefs = useRef