/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent } from "@/components/sheet";
import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react";
import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
createRef,
lazy,
Suspense,
} from "react";
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { resetPermissionsCache } from "@/hooks/use-permissions";
import { MobileBar } from "@/shell/MobileBar";
import { isElectron } from "@/lib/electron";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
isCoreRailView,
railItemLabel,
promotableIds,
rightDockableIds,
useRailItems,
} from "@/sidebar/rail-items";
import {
DOCK_DEFAULT_WIDTH,
DockPanel,
DockReopenStrip,
} from "@/shell/DockPanel";
import {
SurfaceScope,
useConfirm,
type ConfirmOptions,
type EditingWidth,
} from "@/components/surface/surface-scope";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
import {
EmptyPanePicker,
type PickHostTarget,
} from "@/shell/split/EmptyPanePicker";
import { renderTabContent } from "@/shell/tabUtils";
import { TabBar } from "@/shell/TabBar";
import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
import { canCloseTab } from "@/shell/tab-close-guards";
import {
MANAGE_REQUEST_EVENT,
type ManageMode,
requestFromLegacyEvent,
requestManage,
} from "@/manage/manage-requests";
import {
dispatchCtrlW,
createCommandPaletteShortcutMatcher,
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";
import {
GLOBAL_KEYBINDING_EVENT,
getKeybindingAction,
runKeybindingAction,
} from "@/shell/keybinding-registry";
// 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 SyncPanel = lazy(() =>
import("@/settings/sync/SyncPanel").then((m) => ({
default: m.SyncPanel,
})),
);
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 (
);
}
import type {
Tab,
TabType,
Host,
HostFolder,
ThemeId,
FontSizeId,
WorkspacePayload,
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize } from "@/lib/theme";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
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,
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
import {
buildLayoutPayload,
resolveLayoutSplits,
resolveLayoutTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { remScale } from "@/lib/rem";
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 {
addSplitTab,
canJoinSplit,
closeSplitTab,
isSplitTab,
makeSplitTab,
nextSplitNumber,
placeTabInPane,
removeTab,
restoreSplitTabs,
serializeSplitTabs,
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";
import {
OPEN_PLUGINS_EVENT,
OPEN_PLUGIN_SETTINGS_EVENT,
} from "@/plugins/open-plugins";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
/** Hands the main area's confirm to code outside it, like closeTab. */
function ShellConfirmCapture({
confirmRef,
}: {
confirmRef: React.MutableRefObject<
((options: ConfirmOptions) => Promise) | null
>;
}) {
const confirm = useConfirm();
confirmRef.current = confirm;
return null;
}
// ─── 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 tabTypes = useTabTypes();
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);
// Split tabs are restored once their child tabs have stable live ids.
const paneLayoutRestoredRef = useRef(false);
const splitTabsRestoredRef = useRef(false);
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 [backgroundTabRecords, setBackgroundTabRecords] = useState<
OpenTabRecord[]
>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState("hosts");
// Anything that asks to add or edit a host, a credential or host defaults
// ends up in the Manage tab, whichever event it still speaks.
useEffect(() => {
const names = [
"host-manager:add-host",
"host-manager:edit-host",
"host-manager:add-credential",
"host-manager:show-credentials",
"host-manager:edit-defaults",
"termix:open-host-defaults",
];
const handler = (e: Event) => {
const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail);
if (request) requestManage(request);
};
const open = () => openSingletonTabRef.current("host-manager");
for (const name of names) window.addEventListener(name, handler);
window.addEventListener(MANAGE_REQUEST_EVENT, open);
return () => {
for (const name of names) window.removeEventListener(name, handler);
window.removeEventListener(MANAGE_REQUEST_EVENT, open);
};
}, []);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291;
});
const [sidebarDragging, setSidebarDragging] = useState(false);
// How far each panel wants the sidebar widened: what the panel says itself,
// and what its open inline views say.
const [panelEditing, setPanelEditing] = useState<
Record
>({});
const [scopeEditing, setScopeEditing] = useState<
Record
>({});
const setPanelEditingFor = useCallback(
(view: string, editing: EditingWidth) =>
setPanelEditing((prev) =>
prev[view] === editing ? prev : { ...prev, [view]: editing },
),
[],
);
const setScopeEditingFor = useCallback(
(view: string, editing: EditingWidth) =>
setScopeEditing((prev) =>
prev[view] === editing ? prev : { ...prev, [view]: editing },
),
[],
);
const editingSetters = useRef(
new Map void>(),
);
const panelEditingSetter = (view: string) => {
const key = `panel:${view}`;
if (!editingSetters.current.has(key))
editingSetters.current.set(key, (e) => setPanelEditingFor(view, e));
return editingSetters.current.get(key)!;
};
const scopeEditingSetter = (view: string) => {
const key = `scope:${view}`;
if (!editingSetters.current.has(key))
editingSetters.current.set(key, (e) => setScopeEditingFor(view, e));
return editingSetters.current.get(key)!;
};
// The root confirm, for questions the shell itself asks (closing a tab).
const shellConfirmRef = useRef<
((options: ConfirmOptions) => Promise) | null
>(null);
// 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 sidebarOpenBeforeMobile = useRef(sidebarOpen);
useEffect(() => {
if (isMobile) {
sidebarOpenBeforeMobile.current = sidebarOpen;
setSidebarOpen(false);
} else {
setSidebarOpen(sidebarOpenBeforeMobile.current);
}
}, [isMobile]);
useEffect(() => {
getUserInfo()
.then((info) => setIsAdmin(info.is_admin))
.catch(() => setIsAdmin(false));
}, []);
const toggleAppFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
if (!document.fullscreenEnabled) {
toast.error(t("nav.fullscreenUnsupported"));
return;
}
await document.documentElement.requestFullscreen();
} catch {
toast.error(t("nav.fullscreenFailed"));
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
setIsAppFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const tabsRef = useRef(tabs);
const activeTabIdRef = useRef(activeTabId);
const closeActiveTabRef = useRef<() => void>(() => {});
const globalKeybindingsRef = useRef([]);
// 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);
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);
})
.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 = 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,
);
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;
// Only actions that run anywhere; a terminal's own are its to handle.
const binding = findMatchingKeybinding(
event,
globalKeybindingsRef.current.filter(
(entry) => getKeybindingAction(entry.action.type)?.scope === "global",
),
);
if (!binding) return;
event.preventDefault();
event.stopPropagation();
runKeybindingAction(binding.action);
};
const handleAction = (event: Event) =>
runAction(
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
};
}, []);
useEffect(() => {
activeTabIdRef.current = activeTabId;
}, [activeTabId]);
useEffect(() => {
return window.electronAPI?.onCloseActiveTab?.(() => {
if (dispatchCtrlW(document.activeElement)) return;
closeActiveTabRef.current();
});
}, []);
/** 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;
}
// ─── 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);
return;
}
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".
// Once those panels can themselves be the active tab, activeTabId points at
// the panel and the lookup misses, so remember the last terminal instead.
// 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 ?? "";
})();
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
useEffect(() => {
const working = tabs.find((t) => t.id === workingTabId);
if (working && getTabType(working.type)?.commandTarget) {
setLastTerminalTabId(working.id);
}
}, [workingTabId, tabs]);
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