mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
3203 lines
109 KiB
TypeScript
3203 lines
109 KiB
TypeScript
/* 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 { 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 { 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 (
|
|
<div className="flex flex-1 items-center justify-center p-6">
|
|
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
import type {
|
|
Tab,
|
|
TabType,
|
|
Host,
|
|
HostFolder,
|
|
ThemeId,
|
|
FontSizeId,
|
|
WorkspacePayload,
|
|
} from "@/types/ui-types";
|
|
import { applyAccentColor, applyFontSize } 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,
|
|
type UserPreferences,
|
|
type OpenTabRecord,
|
|
} from "@/main-axios";
|
|
import {
|
|
buildLayoutPayload,
|
|
resolveLayoutSplits,
|
|
resolveLayoutTabTarget,
|
|
snapshotData,
|
|
} from "@/shell/shell-layout";
|
|
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
|
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<boolean>) | 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<Tab[]>([
|
|
{
|
|
id: "dashboard",
|
|
instanceId: "dashboard",
|
|
type: "dashboard",
|
|
label: t("nav.dashboard"),
|
|
openedAt: Date.now(),
|
|
},
|
|
]);
|
|
const [activeTabId, setActiveTabId] = useState("dashboard");
|
|
const [userPrefs, setUserPrefs] = useState<UserPreferences>({
|
|
reopenTabsOnLogin: false,
|
|
});
|
|
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
|
|
const [hostsLoaded, setHostsLoaded] = useState(false);
|
|
// Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this
|
|
const [tabsReady, setTabsReady] = useState(false);
|
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
// Split tabs are restored once their child tabs have stable live ids.
|
|
const paneLayoutRestoredRef = useRef(false);
|
|
const splitTabsRestoredRef = useRef(false);
|
|
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
|
const [hostsLoading, setHostsLoading] = useState(true);
|
|
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
|
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 [showDonationModal, setShowDonationModal] = useState(false);
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
|
OpenTabRecord[]
|
|
>([]);
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [railView, setRailView] = useState<RailView>("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<string, EditingWidth>
|
|
>({});
|
|
const [scopeEditing, setScopeEditing] = useState<
|
|
Record<string, EditingWidth>
|
|
>({});
|
|
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<string, (editing: EditingWidth) => 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<boolean>) | 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<RailView | null>(() => {
|
|
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<string | null>(
|
|
localStorage.getItem("termix_lastRightRailView"),
|
|
);
|
|
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
|
() => !!document.fullscreenElement,
|
|
);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
|
}, [sidebarWidth]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
|
|
}, [rightSidebarWidth]);
|
|
|
|
useEffect(() => {
|
|
if (rightRailView) {
|
|
localStorage.setItem("termix_rightRailView", rightRailView);
|
|
lastRightRailViewRef.current = rightRailView;
|
|
localStorage.setItem("termix_lastRightRailView", rightRailView);
|
|
} else {
|
|
localStorage.removeItem("termix_rightRailView");
|
|
}
|
|
}, [rightRailView]);
|
|
|
|
useEffect(() => {
|
|
if (!splitTabsRestoredRef.current) return;
|
|
localStorage.setItem(
|
|
"termix_splitTabs",
|
|
JSON.stringify(serializeSplitTabs(tabs)),
|
|
);
|
|
}, [tabs]);
|
|
|
|
const isMobile = useIsMobile();
|
|
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
sidebarOpenBeforeMobile.current = sidebarOpen;
|
|
setSidebarOpen(false);
|
|
} else {
|
|
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
|
}
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
getUserInfo()
|
|
.then((info) => {
|
|
setIsAdmin(info.is_admin);
|
|
setShowDonationModal(!!info.show_donation_modal);
|
|
})
|
|
.catch(() => setIsAdmin(false));
|
|
}, []);
|
|
|
|
const handleDismissDonationModal = useCallback(() => {
|
|
setShowDonationModal(false);
|
|
dismissDonationModal().catch(() => {});
|
|
}, []);
|
|
|
|
const toggleAppFullscreen = useCallback(async () => {
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
return;
|
|
}
|
|
|
|
if (!document.fullscreenEnabled) {
|
|
toast.error(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<CustomKeybinding[]>([]);
|
|
// 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<boolean>(() => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
return v !== null ? v === "true" : true;
|
|
});
|
|
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
|
|
() => localStorage.getItem("showTabNumbers") === "true",
|
|
);
|
|
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
|
new Map(),
|
|
);
|
|
// 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);
|
|
|
|
// 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);
|
|
// The area below the tab bar, where tabs and panes are dropped to split.
|
|
const mainAreaRef = useRef<HTMLDivElement>(null);
|
|
// Tab id the enter animation has already played for, so a re-render while
|
|
// the tab stays active (there can be several right after a switch) doesn't
|
|
// replay it — only a genuine switch to a different tab should.
|
|
const lastAnimatedTabIdRef = useRef<string | null>(null);
|
|
|
|
const getTabNode = useCallback((tabId: string, isTerminal: boolean) => {
|
|
if (!tabNodesRef.current.has(tabId)) {
|
|
const el = document.createElement("div");
|
|
el.style.position = "absolute";
|
|
el.style.inset = "0";
|
|
el.style.overflow = "hidden";
|
|
if (!isTerminal) el.classList.add("bg-background");
|
|
tabNodesRef.current.set(tabId, el);
|
|
}
|
|
return tabNodesRef.current.get(tabId)!;
|
|
}, []);
|
|
|
|
// Portal render order for tab content, kept independent of the tab bar's
|
|
// visual order. Reordering tabs in the bar reorders `tabs`, and mapping
|
|
// that array directly to portals reshuffles the Suspense-wrapped portal
|
|
// children's sibling order in the fiber tree — React then runs its
|
|
// Offscreen disconnect/reconnect pass on the ones that moved, which tears
|
|
// down and rebuilds every passive effect underneath (including
|
|
// react-xtermjs's terminal-creation effect), dropping the live terminal
|
|
// and its WebSocket. Portal position doesn't need to track tab order at
|
|
// all, so we only ever append new ids and drop closed ones here.
|
|
const portalOrderRef = useRef<string[]>([]);
|
|
{
|
|
const liveIds = new Set(tabs.map((t) => t.id));
|
|
portalOrderRef.current = portalOrderRef.current.filter((id) =>
|
|
liveIds.has(id),
|
|
);
|
|
const known = new Set(portalOrderRef.current);
|
|
for (const tab of tabs) {
|
|
if (!known.has(tab.id)) portalOrderRef.current.push(tab.id);
|
|
}
|
|
}
|
|
const tabsByPortalOrder = portalOrderRef.current
|
|
.map((id) => tabs.find((t) => t.id === id))
|
|
.filter((t): t is Tab => t !== undefined);
|
|
|
|
const onPaneContentRef = useCallback(
|
|
(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);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Titles come from the shared rail definitions so they stay translated and
|
|
// in step with the rail itself.
|
|
const sidebarTitle = (view: RailView): string => railItemLabel(view, t);
|
|
|
|
// Double-shift or Ctrl+K opens the command palette. Double-shift alone was
|
|
// hard to discover.
|
|
useEffect(() => {
|
|
if (!commandPaletteShortcutEnabled) return;
|
|
const shortcut = createCommandPaletteShortcutMatcher();
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!shortcut.matches(e)) return;
|
|
if (!isShiftKey(e)) e.preventDefault();
|
|
setCommandPaletteOpen((prev) => !prev);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
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);
|
|
};
|
|
}, [commandPaletteShortcutEnabled]);
|
|
|
|
// Ctrl+Shift+E toggles between the two most recent sidebar panels.
|
|
const previousRailViewRef = useRef<RailView | null>(null);
|
|
const currentRailViewRef = useRef(railView);
|
|
useEffect(() => {
|
|
if (currentRailViewRef.current !== railView) {
|
|
previousRailViewRef.current = currentRailViewRef.current;
|
|
currentRailViewRef.current = railView;
|
|
}
|
|
}, [railView]);
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return;
|
|
e.preventDefault();
|
|
const previous = previousRailViewRef.current;
|
|
if (!sidebarOpen) {
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
if (previous && previous !== railView) handleRailClick(previous);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [railView, sidebarOpen]);
|
|
|
|
// Split-screen and tab navigation hotkeys
|
|
// Also registered in globalShortcutHandler so xterm can invoke directly
|
|
// without going through synthetic DOM events (which are unreliable).
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
if (e.code === "KeyF") {
|
|
e.preventDefault();
|
|
toggleAppFullscreen();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ctrl+Shift+\ 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") {
|
|
e.preventDefault();
|
|
splitActionsRef.current?.splitActive(
|
|
e.code === "Backslash" ? "right" : "bottom",
|
|
);
|
|
return;
|
|
}
|
|
if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Cmd+1..9 on macOS, Alt+1..9 elsewhere — jump directly to the tab at that position
|
|
const tabDigit = getTabJumpDigit(e);
|
|
if (tabDigit !== null) {
|
|
const currentTabs = tabsRef.current.filter(
|
|
(tab) => !tab.parentSplitTabId,
|
|
);
|
|
const index = tabDigit - 1;
|
|
if (index < currentTabs.length) {
|
|
e.preventDefault();
|
|
setActiveTabId(currentTabs[index].id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+] / Ctrl+Shift+[ — cycle through open tabs (] = next, [ = previous)
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
|
e.preventDefault();
|
|
const currentTabs = tabsRef.current.filter(
|
|
(tab) => !tab.parentSplitTabId,
|
|
);
|
|
if (currentTabs.length < 2) return;
|
|
const currentId = activeTabIdRef.current;
|
|
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
|
const next =
|
|
e.code === "BracketRight"
|
|
? (idx + 1) % currentTabs.length
|
|
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
|
setActiveTabId(currentTabs[next].id);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
globalShortcutHandler.current = handleKeyDown;
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
globalShortcutHandler.current = null;
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
|
};
|
|
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
|
return () =>
|
|
window.removeEventListener(
|
|
"commandPaletteShortcutEnabledChanged",
|
|
handler,
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true");
|
|
};
|
|
window.addEventListener("showTabNumbersChanged", handler);
|
|
return () => window.removeEventListener("showTabNumbersChanged", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handle = (event: Event) => {
|
|
const manual =
|
|
event instanceof CustomEvent &&
|
|
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
|
// Drop the cached grants so the next user never inherits them.
|
|
resetPermissionsCache();
|
|
onLogout(manual ? { manual: true } : undefined);
|
|
};
|
|
window.addEventListener("termix:logout", handle);
|
|
return () => window.removeEventListener("termix:logout", handle);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const handleSessionExpired = () => onLogout();
|
|
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
|
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
if (!activeTab?.terminalRef) return;
|
|
let innerRafId: number;
|
|
const outerRafId = requestAnimationFrame(() => {
|
|
innerRafId = requestAnimationFrame(() => {
|
|
const ref = activeTab.terminalRef?.current;
|
|
ref?.fit?.();
|
|
ref?.notifyResize?.();
|
|
ref?.refresh?.();
|
|
});
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(outerRafId);
|
|
cancelAnimationFrame(innerRafId);
|
|
};
|
|
}, [activeTabId]);
|
|
|
|
useEffect(() => {
|
|
const handleDegraded = () => {
|
|
toast.loading(t("common.connectionDegraded"), {
|
|
id: "db-connection-degraded",
|
|
duration: Infinity,
|
|
dismissible: false,
|
|
action: {
|
|
label: t("common.reload"),
|
|
onClick: () => window.location.reload(),
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRestored = () => {
|
|
toast.dismiss("db-connection-degraded");
|
|
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
|
};
|
|
|
|
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
|
|
|
return () => {
|
|
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.off(
|
|
"database-connection-degraded-cleared",
|
|
handleRestored,
|
|
);
|
|
};
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
getUserPreferences()
|
|
.then((prefs) => {
|
|
const loginLanguage = consumeLoginLanguage();
|
|
setUserPrefs(prefs);
|
|
if (prefs.storageMode === "cloud") {
|
|
// Persist the current browser values before overwriting, so any tab can restore them
|
|
if (!localStorage.getItem("termix-local-snapshot")) {
|
|
const SNAPSHOT_KEYS = [
|
|
"termix-accent",
|
|
"termix-font-size",
|
|
"termix-ui-font",
|
|
"i18nextLng",
|
|
"commandPaletteShortcutEnabled",
|
|
"showHostTags",
|
|
"hostTrayOnClick",
|
|
"pinAppRail",
|
|
"expandAppRailOnHover",
|
|
"disableUpdateCheck",
|
|
"confirmTabClose",
|
|
"hiddenRailTabs",
|
|
];
|
|
const snap: Record<string, string | null> = {
|
|
__theme: localStorage.getItem("termix-theme"),
|
|
};
|
|
for (const key of SNAPSHOT_KEYS)
|
|
snap[key] = localStorage.getItem(key);
|
|
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
|
}
|
|
if (prefs.theme) setTheme(prefs.theme as ThemeId);
|
|
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
|
if (prefs.accentColor) {
|
|
localStorage.setItem("termix-accent", prefs.accentColor);
|
|
applyAccentColor(prefs.accentColor);
|
|
}
|
|
const preferredLanguage = loginLanguage ?? prefs.language;
|
|
if (preferredLanguage && preferredLanguage !== i18n.language) {
|
|
void changeAppLanguage(preferredLanguage);
|
|
}
|
|
if (loginLanguage && loginLanguage !== prefs.language) {
|
|
void saveUserPreferences({ language: loginLanguage });
|
|
}
|
|
if (
|
|
prefs.commandPaletteEnabled !== null &&
|
|
prefs.commandPaletteEnabled !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
String(prefs.commandPaletteEnabled),
|
|
);
|
|
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
|
|
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
|
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
|
}
|
|
if (
|
|
prefs.hostTrayOnClick !== null &&
|
|
prefs.hostTrayOnClick !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"hostTrayOnClick",
|
|
String(prefs.hostTrayOnClick),
|
|
);
|
|
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
|
|
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
|
}
|
|
if (
|
|
prefs.expandAppRailOnHover !== null &&
|
|
prefs.expandAppRailOnHover !== undefined
|
|
) {
|
|
localStorage.setItem(
|
|
"expandAppRailOnHover",
|
|
String(prefs.expandAppRailOnHover),
|
|
);
|
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
|
}
|
|
if (
|
|
prefs.disableUpdateCheck !== null &&
|
|
prefs.disableUpdateCheck !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"disableUpdateCheck",
|
|
String(prefs.disableUpdateCheck),
|
|
);
|
|
if (
|
|
prefs.confirmTabClose !== null &&
|
|
prefs.confirmTabClose !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"confirmTabClose",
|
|
String(prefs.confirmTabClose),
|
|
);
|
|
if (
|
|
prefs.hiddenRailTabs !== null &&
|
|
prefs.hiddenRailTabs !== undefined
|
|
) {
|
|
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setUserPrefsLoaded(true));
|
|
}, []);
|
|
|
|
// Load real hosts from API
|
|
const loadHosts = useCallback(async () => {
|
|
try {
|
|
const [raw, folders] = await Promise.all([
|
|
getSSHHosts(),
|
|
getSSHFolders().catch(() => []),
|
|
]);
|
|
const converted = raw.map(sshHostToHost);
|
|
setAllHosts(converted);
|
|
const folderMeta = new Map<
|
|
string,
|
|
{
|
|
color?: string;
|
|
icon?: string;
|
|
credentialId?: number | null;
|
|
sortOrder?: number | null;
|
|
localOnly?: boolean;
|
|
}
|
|
>();
|
|
for (const f of folders) {
|
|
folderMeta.set(f.name, {
|
|
color: f.color ?? undefined,
|
|
icon: f.icon ?? undefined,
|
|
credentialId: f.credentialId ?? null,
|
|
sortOrder: f.sortOrder ?? null,
|
|
localOnly: !!f.localOnly,
|
|
});
|
|
}
|
|
setRealHostTree(buildHostTree(raw, folderMeta));
|
|
} catch {
|
|
// Keep empty state on error
|
|
} finally {
|
|
setHostsLoading(false);
|
|
setHostsLoaded(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadHosts();
|
|
}, [loadHosts]);
|
|
|
|
useEffect(() => {
|
|
const onHostsChanged = () => {
|
|
void loadHosts();
|
|
};
|
|
window.addEventListener("termix:hosts-changed", onHostsChanged);
|
|
window.addEventListener("ssh-hosts:changed", onHostsChanged);
|
|
window.addEventListener("hosts:refresh", onHostsChanged);
|
|
return () => {
|
|
window.removeEventListener("termix:hosts-changed", onHostsChanged);
|
|
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
|
|
window.removeEventListener("hosts:refresh", onHostsChanged);
|
|
};
|
|
}, [loadHosts]);
|
|
|
|
// Keeps the desktop's sync status polled while the app is open; a pass
|
|
// that changed data tells the panels to reload.
|
|
useSyncStatus();
|
|
|
|
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
|
useEffect(() => {
|
|
if (allHosts.length === 0) return;
|
|
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;
|
|
});
|
|
}, [allHosts]);
|
|
|
|
function buildWorkspacePayload(): WorkspacePayload {
|
|
return buildLayoutPayload({
|
|
tabs,
|
|
activeTabId,
|
|
sidebar: {
|
|
left: { view: railView, open: sidebarOpen, width: sidebarWidth },
|
|
right: {
|
|
view: rightRailView,
|
|
open: rightRailView !== null,
|
|
width: rightSidebarWidth,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function applyLayout(
|
|
payload: WorkspacePayload,
|
|
name: string,
|
|
): Promise<{ skipped: string[] }> {
|
|
// Tear down the current arrangement the same way an individual tab close does.
|
|
for (const tab of [...tabsRef.current]) {
|
|
doCloseTab(tab.id);
|
|
}
|
|
// 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[] = [];
|
|
|
|
for (const snapshot of payload.tabs) {
|
|
const target = resolveLayoutTabTarget(snapshot, allHostsRef.current);
|
|
|
|
if (target.kind === "skip") {
|
|
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
|
|
continue;
|
|
}
|
|
|
|
if (target.kind === "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);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
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);
|
|
}
|
|
|
|
return { skipped: skippedTabs };
|
|
} finally {
|
|
suppressPaneTargetRef.current = false;
|
|
}
|
|
}
|
|
|
|
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
|
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
|
const tabRestoreAttemptedRef = useRef(false);
|
|
useEffect(() => {
|
|
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
|
|
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
|
|
if (tabRestoreAttemptedRef.current) return;
|
|
tabRestoreAttemptedRef.current = true;
|
|
|
|
async function loadSavedTabs() {
|
|
try {
|
|
const [savedTabs, activeSessions] = await Promise.all([
|
|
getOpenTabs(),
|
|
getActiveSessions(),
|
|
]);
|
|
|
|
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return;
|
|
|
|
const sessionByInstanceId = new Map(
|
|
(Array.isArray(activeSessions) ? activeSessions : [])
|
|
.filter((s) => s.tabInstanceId != null)
|
|
.map((s) => [s.tabInstanceId, s]),
|
|
);
|
|
|
|
if (userPrefs.reopenTabsOnLogin) {
|
|
const hasPersistentTabs = tabs.some((t) =>
|
|
isPersistentTabType(t.type),
|
|
);
|
|
if (!hasPersistentTabs) {
|
|
const restoredTabs: Tab[] = [];
|
|
for (const saved of savedTabs as OpenTabRecord[]) {
|
|
const host = saved.hostId
|
|
? allHosts.find((h) => h.id === String(saved.hostId))
|
|
: undefined;
|
|
if (!canRestoreTabType(saved.tabType, host)) continue;
|
|
|
|
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
|
const tabId = host
|
|
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
|
|
: saved.id;
|
|
const liveSession = sessionByInstanceId.get(saved.id);
|
|
const restoredSessionId =
|
|
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
|
|
|
const isCustomLabel =
|
|
host &&
|
|
saved.label !== host.name &&
|
|
!/^.+ \(\d+\)$/.test(saved.label);
|
|
|
|
restoredTabs.push({
|
|
id: tabId,
|
|
instanceId: saved.id,
|
|
type: saved.tabType as TabType,
|
|
label: saved.label,
|
|
customLabel: isCustomLabel ? saved.label : undefined,
|
|
host,
|
|
openedAt: new Date(saved.createdAt).getTime(),
|
|
restoredSessionId,
|
|
terminalRef: isSessionTabType(saved.tabType)
|
|
? createRef()
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
if (restoredTabs.length > 0) {
|
|
setTabs((prev) => {
|
|
const existingIds = new Set(prev.map((t) => t.id));
|
|
const newTabs = restoredTabs.filter(
|
|
(t) => !existingIds.has(t.id),
|
|
);
|
|
return newTabs.length > 0 ? [...prev, ...newTabs] : prev;
|
|
});
|
|
setActiveTabId(restoredTabs[0].id);
|
|
}
|
|
// Restored tabs are in the tab bar, not in background records
|
|
}
|
|
} else {
|
|
// Not restoring to tab bar — keep as background records for ConnectionsPanel
|
|
setBackgroundTabRecords(savedTabs as OpenTabRecord[]);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setTabsReady(true);
|
|
}
|
|
}
|
|
|
|
loadSavedTabs();
|
|
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
|
|
|
|
// Plugins that act once the session is back (the workspaces plugin applies
|
|
// a default workspace) wait for this.
|
|
useEffect(() => {
|
|
if (tabsReady) notifyShellReady();
|
|
}, [tabsReady]);
|
|
|
|
// Restore split tabs once their child sessions have stable live ids. The
|
|
// oldest single-split keys are migrated once into a first split.
|
|
useEffect(() => {
|
|
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
|
paneLayoutRestoredRef.current = true;
|
|
|
|
try {
|
|
const savedSplitTabs = JSON.parse(
|
|
localStorage.getItem("termix_splitTabs") ?? "[]",
|
|
) as unknown;
|
|
if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) {
|
|
setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev));
|
|
return;
|
|
}
|
|
|
|
const savedInstanceIds = JSON.parse(
|
|
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
|
) 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]),
|
|
);
|
|
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);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
splitTabsRestoredRef.current = true;
|
|
localStorage.removeItem("termix_splitMode");
|
|
localStorage.removeItem("termix_paneInstanceIds");
|
|
localStorage.removeItem("termix_paneSizes");
|
|
}
|
|
}, [tabsReady, tabs]);
|
|
|
|
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
|
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
);
|
|
const prevTabOrderRef = useRef<string>("");
|
|
useEffect(() => {
|
|
if (!tabsReady) return;
|
|
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
|
|
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
|
if (orderKey === prevTabOrderRef.current) return;
|
|
prevTabOrderRef.current = orderKey;
|
|
|
|
if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current);
|
|
orderSyncTimeoutRef.current = setTimeout(() => {
|
|
persistable.forEach((t, i) => {
|
|
patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {});
|
|
});
|
|
}, 500);
|
|
|
|
return () => {
|
|
if (orderSyncTimeoutRef.current)
|
|
clearTimeout(orderSyncTimeoutRef.current);
|
|
};
|
|
}, [tabs, tabsReady]);
|
|
|
|
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
|
|
// keep its last-session snapshot current.
|
|
useEffect(() => {
|
|
if (tabsReady) notifyTabsChanged();
|
|
}, [
|
|
tabs,
|
|
tabsReady,
|
|
railView,
|
|
sidebarOpen,
|
|
sidebarWidth,
|
|
rightRailView,
|
|
rightSidebarWidth,
|
|
]);
|
|
|
|
// ─── Tab management ──────────────────────────────────────────────────────
|
|
|
|
const openTab = useCallback(function openTab(
|
|
host: Host,
|
|
type: TabType,
|
|
restore?: {
|
|
instanceId: string;
|
|
restoredSessionId: string | null;
|
|
savedLabel?: string;
|
|
},
|
|
options?: {
|
|
data?: Record<string, unknown>;
|
|
label?: string;
|
|
forceNewTab?: boolean;
|
|
},
|
|
) {
|
|
if (!restore && !options?.forceNewTab) {
|
|
const dataKey = JSON.stringify(options?.data ?? null);
|
|
const existing = tabsRef.current.find(
|
|
(t) =>
|
|
t.type === type &&
|
|
t.host?.id === host.id &&
|
|
JSON.stringify(t.data ?? null) === dataKey,
|
|
);
|
|
if (existing) {
|
|
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()}`;
|
|
const openedAt = Date.now();
|
|
const ref = isSessionTabType(type) ? createRef() : undefined;
|
|
if (ref) terminalRefs.current.set(tabId, ref);
|
|
|
|
let finalLabel = host.name;
|
|
const savedLabel = restore?.savedLabel;
|
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
|
const isCustomLabel =
|
|
savedLabel != null &&
|
|
savedLabel !== host.name &&
|
|
!/^.+ \(\d+\)$/.test(savedLabel);
|
|
|
|
setTabs((prev) => {
|
|
if (isCustomLabel && savedLabel) {
|
|
finalLabel = savedLabel;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: tabId,
|
|
instanceId,
|
|
type,
|
|
label: finalLabel,
|
|
customLabel: finalLabel,
|
|
host,
|
|
openedAt,
|
|
terminalRef: ref,
|
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
|
data: options?.data,
|
|
},
|
|
];
|
|
}
|
|
|
|
const same = prev.filter(
|
|
(t) =>
|
|
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
|
);
|
|
finalLabel = options?.label
|
|
? options.label
|
|
: same.length === 0
|
|
? host.name
|
|
: `${host.name} (${same.length + 1})`;
|
|
|
|
// Retrofit the first duplicate's label to "(1)" if needed
|
|
const next =
|
|
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
|
? prev.map((t) =>
|
|
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
|
)
|
|
: prev;
|
|
|
|
return [
|
|
...next,
|
|
{
|
|
id: tabId,
|
|
instanceId,
|
|
type,
|
|
label: finalLabel,
|
|
host,
|
|
openedAt,
|
|
terminalRef: ref,
|
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
|
data: options?.data,
|
|
},
|
|
];
|
|
});
|
|
placeOpenedTabRef.current(tabId);
|
|
|
|
if (isPersistentTabType(type)) {
|
|
addOpenTab({
|
|
id: instanceId,
|
|
tabType: type,
|
|
hostId: host ? parseInt(host.id) : null,
|
|
label: finalLabel,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
return tabId;
|
|
}, []);
|
|
|
|
function connectHost(
|
|
host: Host,
|
|
preferredType?: TabType,
|
|
options?: {
|
|
data?: Record<string, unknown>;
|
|
label?: string;
|
|
forceNewTab?: boolean;
|
|
},
|
|
) {
|
|
const type = resolveHostTabType(host, preferredType);
|
|
if (!type) return;
|
|
openTab(host, type, undefined, options);
|
|
}
|
|
|
|
const saveQuickConnectHost = useCallback(
|
|
async (tab: Tab, host: Host) => {
|
|
try {
|
|
const savedHost = await createSSHHost(quickConnectHostToPayload(host));
|
|
await patchOpenTab(tab.instanceId, { hostId: savedHost.id });
|
|
await loadHosts();
|
|
toast.success(t("hosts.hostCreated"));
|
|
} catch (error) {
|
|
toast.error(t("hosts.failedToSave"));
|
|
throw error;
|
|
}
|
|
},
|
|
[loadHosts, t],
|
|
);
|
|
|
|
/** 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);
|
|
setTabs((current) => {
|
|
const count = current.filter((tab) => tab.type === type).length;
|
|
return [
|
|
...current,
|
|
{
|
|
id,
|
|
instanceId,
|
|
type,
|
|
label:
|
|
count === 0 || options?.label ? title : `${title} (${count + 1})`,
|
|
openedAt: Date.now(),
|
|
...(options?.host ? { host: options.host } : {}),
|
|
...(options?.data !== undefined ? { data: options.data } : {}),
|
|
},
|
|
];
|
|
});
|
|
placeOpenedTabRef.current(id);
|
|
return id;
|
|
}
|
|
|
|
const openSingletonTab = useCallback(
|
|
// `host` optionally preselects a host for a singleton plugin tab.
|
|
function openSingletonTab(
|
|
type: TabType,
|
|
pendingEvent?: string,
|
|
host?: Host,
|
|
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 });
|
|
}
|
|
if (type === "host-manager" && pendingEvent) {
|
|
const request = requestFromLegacyEvent(pendingEvent);
|
|
if (request) requestManage(request);
|
|
}
|
|
// Profile and admin are pages of the one Settings tab now.
|
|
if (type === "user-profile" || type === "admin-settings") {
|
|
return openSingletonTab(
|
|
"settings",
|
|
undefined,
|
|
undefined,
|
|
{
|
|
section:
|
|
typeof data?.section === "string"
|
|
? data.section
|
|
: type === "admin-settings"
|
|
? "admin-general"
|
|
: "account",
|
|
},
|
|
tabLabel,
|
|
);
|
|
}
|
|
// Asking for a page again still goes there after the user moved away
|
|
if (type === "settings" && data?.section && data.revealAt === undefined) {
|
|
data = { ...data, revealAt: Date.now() };
|
|
}
|
|
const id = type;
|
|
const singletonLabels: Partial<Record<TabType, string>> = {
|
|
"host-manager": t("nav.manage"),
|
|
settings: t("nav.settings"),
|
|
plugins: t("nav.plugins"),
|
|
};
|
|
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
|
// label so the two stay in sync.
|
|
const titleKey = getTabType(type)?.titleKey;
|
|
const label =
|
|
singletonLabels[type] ??
|
|
(titleKey ? t(titleKey) : railItemLabel(type, t));
|
|
setTabs((prev) => {
|
|
const existing = prev.find((t) => t.id === id);
|
|
if (existing) {
|
|
// Refocusing a singleton with a host or data passes it on.
|
|
if (!host && data === undefined) return prev;
|
|
return prev.map((t) =>
|
|
t.id === id
|
|
? {
|
|
...t,
|
|
...(host ? { host } : {}),
|
|
...(data !== undefined ? { data } : {}),
|
|
}
|
|
: t,
|
|
);
|
|
}
|
|
return [
|
|
...prev,
|
|
{
|
|
id,
|
|
instanceId: id,
|
|
type,
|
|
label,
|
|
openedAt: Date.now(),
|
|
...(host ? { host } : {}),
|
|
...(data !== undefined ? { data } : {}),
|
|
},
|
|
];
|
|
});
|
|
placeOpenedTabRef.current(id);
|
|
if (isPersistentTabType(type)) {
|
|
addOpenTab({
|
|
id,
|
|
tabType: type,
|
|
hostId: null,
|
|
label,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
return id;
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const openSingletonTabRef = useRef(openSingletonTab);
|
|
openSingletonTabRef.current = openSingletonTab;
|
|
|
|
useEffect(() => {
|
|
const handler = () => openSingletonTabRef.current("plugins");
|
|
window.addEventListener(OPEN_PLUGINS_EVENT, handler);
|
|
return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = (event: Event) => {
|
|
const pluginId = (event as CustomEvent<string>).detail;
|
|
if (typeof pluginId !== "string") return;
|
|
openSingletonTabRef.current("settings", undefined, undefined, {
|
|
section: `plugin:${pluginId}`,
|
|
revealAt: Date.now(),
|
|
});
|
|
};
|
|
window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
|
return () =>
|
|
window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
|
}, []);
|
|
|
|
const getTabCloseLabel = useCallback((tab: Tab) => {
|
|
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
|
}, []);
|
|
|
|
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
|
if (!isSessionTabType(tab.type)) return false;
|
|
return tab.terminalRef?.current?.isConnected?.() === true;
|
|
}, []);
|
|
|
|
const hasActiveConnection = useCallback(() => {
|
|
return tabsRef.current.some(isActiveConnectionTab);
|
|
}, [isActiveConnectionTab]);
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!hasActiveConnection()) return;
|
|
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
return "";
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
};
|
|
}, [hasActiveConnection]);
|
|
|
|
const doCloseTabRef = useRef<(id: string) => void>(() => {});
|
|
doCloseTabRef.current = doCloseTab;
|
|
function doCloseTab(id: string) {
|
|
const tabToClose = tabs.find((t) => t.id === id);
|
|
if (tabToClose?.terminalRef?.current?.disconnect) {
|
|
tabToClose.terminalRef.current.disconnect();
|
|
}
|
|
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
|
|
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
|
}
|
|
|
|
terminalRefs.current.delete(id);
|
|
if (isSplitTab(tabToClose)) {
|
|
unsplit(id);
|
|
return;
|
|
}
|
|
if (id === activeTabId) {
|
|
const remaining = tabs.filter(
|
|
(tab) => tab.id !== id && !tab.parentSplitTabId,
|
|
);
|
|
setActiveTabId(
|
|
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
|
);
|
|
}
|
|
setTabs((prev) => {
|
|
// A pane that held the tab stays, empty and focused, ready for the next one.
|
|
const next = removeTab(prev, id);
|
|
if (next.length === 0)
|
|
return [
|
|
{
|
|
id: "dashboard",
|
|
instanceId: "dashboard",
|
|
type: "dashboard",
|
|
label: t("nav.dashboard"),
|
|
openedAt: Date.now(),
|
|
},
|
|
];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
const reconnectAllRef = useRef<() => void>(() => {});
|
|
function reconnectAllDisconnected() {
|
|
const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current);
|
|
if (reconnected || !failed)
|
|
toast(
|
|
t(
|
|
reconnected
|
|
? "nav.reconnectingTerminals"
|
|
: "nav.noDisconnectedTerminals",
|
|
{ count: reconnected },
|
|
),
|
|
);
|
|
if (failed)
|
|
toast.error(t("nav.reconnectTerminalsFailed", { count: failed }));
|
|
}
|
|
|
|
reconnectAllRef.current = reconnectAllDisconnected;
|
|
|
|
function refreshTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
const handle = tab?.terminalRef?.current;
|
|
if (!handle) return;
|
|
// Session tabs expose one or the other on their handle.
|
|
if (handle.reconnect) handle.reconnect();
|
|
else handle.refresh?.();
|
|
}
|
|
|
|
async function closeTab(id: string) {
|
|
const tab = tabsRef.current.find((t) => t.id === id);
|
|
const guard = tab?.terminalRef?.current?.confirmClose;
|
|
if (guard && !(await guard())) return;
|
|
if (!(await canCloseTab(id))) return;
|
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
|
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
|
|
const ask = shellConfirmRef.current;
|
|
const ok = ask
|
|
? await ask({
|
|
title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }),
|
|
confirmLabel: t("nav.close"),
|
|
})
|
|
: true;
|
|
if (!ok) return;
|
|
}
|
|
doCloseTabRef.current(id);
|
|
}
|
|
|
|
// In a split, closing acts on the focused pane: its session, or the pane
|
|
// itself once it is empty.
|
|
closeActiveTabRef.current = () => {
|
|
const id = activeTabIdRef.current;
|
|
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;
|
|
}
|
|
if (id !== "dashboard") closeTab(id);
|
|
};
|
|
|
|
function renameTab(tabId: string, newLabel: string) {
|
|
setTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
|
),
|
|
);
|
|
const tab = tabs.find((t) => t.id === tabId);
|
|
if (tab?.instanceId && tab.type !== "split-screen") {
|
|
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ─── Split screen ────────────────────────────────────────────────────────
|
|
|
|
const splitLabel = (number: number) =>
|
|
t("splitScreen.defaultLabel", { number });
|
|
|
|
/**
|
|
* 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),
|
|
),
|
|
);
|
|
return next;
|
|
}
|
|
|
|
function editSplit(
|
|
splitTabId: string,
|
|
fn: (state: SplitState) => SplitState | null,
|
|
) {
|
|
setTabs((prev) => updateSplit(prev, splitTabId, fn));
|
|
}
|
|
|
|
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();
|
|
};
|
|
}, []);
|
|
|
|
// ─── Rail / sidebar ──────────────────────────────────────────────────────
|
|
|
|
// Moving a panel to the right dock rather than copying it: two live copies of
|
|
// the same panel would fight over the shared editing state.
|
|
function openInRightDock(view: RailView) {
|
|
setRightRailView(view);
|
|
if (railView === view) setSidebarOpen(false);
|
|
}
|
|
|
|
// Tab bar toggle: reopens whatever was last in the dock, so it behaves like a
|
|
// show/hide rather than losing the user's choice each time.
|
|
function toggleRightDock() {
|
|
if (rightRailView) {
|
|
lastRightRailViewRef.current = rightRailView;
|
|
setRightRailView(null);
|
|
return;
|
|
}
|
|
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
|
|
if (fallback) setRightRailView(fallback as RailView);
|
|
}
|
|
|
|
function handleRailClick(view: RailView) {
|
|
if (railView === view && sidebarOpen) {
|
|
setSidebarOpen(false);
|
|
} else {
|
|
// A panel lives in one dock at a time, so the left dock reclaims it.
|
|
if (rightRailView === view) setRightRailView(null);
|
|
setRailView(view);
|
|
setSidebarOpen(true);
|
|
}
|
|
}
|
|
|
|
function editHostInManager(host: Host) {
|
|
requestManage({ kind: "host", hostId: host.id });
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
const onSidebarMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setSidebarDragging(true);
|
|
const startX = e.clientX;
|
|
const startW = sidebarWidth;
|
|
// 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;
|
|
function onMove(ev: MouseEvent) {
|
|
clientX = ev.clientX;
|
|
if (frame) return;
|
|
frame = requestAnimationFrame(() => {
|
|
frame = 0;
|
|
setSidebarWidth(
|
|
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
|
|
);
|
|
});
|
|
}
|
|
function onUp() {
|
|
cancelAnimationFrame(frame);
|
|
setSidebarWidth(
|
|
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
|
|
);
|
|
setSidebarDragging(false);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
}
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[sidebarWidth],
|
|
);
|
|
|
|
// Same drag, mirrored: the right dock grows as the pointer moves left.
|
|
const onRightSidebarMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setRightSidebarDragging(true);
|
|
const startX = e.clientX;
|
|
const startW = rightSidebarWidth;
|
|
const scale = remScale();
|
|
let frame = 0;
|
|
let clientX = startX;
|
|
function onMove(ev: MouseEvent) {
|
|
clientX = ev.clientX;
|
|
if (frame) return;
|
|
frame = requestAnimationFrame(() => {
|
|
frame = 0;
|
|
setRightSidebarWidth(
|
|
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
|
|
);
|
|
});
|
|
}
|
|
function onUp() {
|
|
cancelAnimationFrame(frame);
|
|
setRightSidebarWidth(
|
|
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
|
|
);
|
|
setRightSidebarDragging(false);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
}
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[rightSidebarWidth],
|
|
);
|
|
|
|
// Resize all terminals in panes + active terminal when split mode or sidebar changes
|
|
const resizeAllTerminals = useCallback(() => {
|
|
const id = requestAnimationFrame(() => {
|
|
tabs.forEach((tab) => {
|
|
if (!tab.terminalRef) return;
|
|
const ref = tab.terminalRef.current;
|
|
ref?.fit?.();
|
|
ref?.notifyResize?.();
|
|
});
|
|
});
|
|
return id;
|
|
}, [tabs]);
|
|
|
|
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}`
|
|
: "";
|
|
|
|
useEffect(() => {
|
|
// Refitting every terminal on each drag frame is what made sidebar drags
|
|
// stutter; fit once when the drag ends instead.
|
|
if (sidebarDragging || rightSidebarDragging) return;
|
|
const id = resizeAllTerminals();
|
|
return () => cancelAnimationFrame(id);
|
|
}, [
|
|
splitShapeKey,
|
|
sidebarWidth,
|
|
sidebarOpen,
|
|
rightSidebarWidth,
|
|
rightRailView,
|
|
sidebarDragging,
|
|
rightSidebarDragging,
|
|
]);
|
|
|
|
// 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]);
|
|
|
|
// Move each tab's stable DOM node to the right container (pane or normal-view).
|
|
// This is vanilla DOM so React's portal target never changes — changing the portal
|
|
// target causes a remount which is exactly what we're trying to avoid.
|
|
// useLayoutEffect (not useEffect) so visibility/display are corrected before
|
|
// the browser paints — otherwise the previous tab's node can flash on screen
|
|
// for a frame while still visible.
|
|
useLayoutEffect(() => {
|
|
const normalView = normalViewRef.current;
|
|
if (!normalView) return;
|
|
|
|
const tabIds = new Set(tabs.map((t) => t.id));
|
|
|
|
// Remove nodes for closed tabs
|
|
for (const [id, node] of tabNodesRef.current) {
|
|
if (!tabIds.has(id)) {
|
|
node.remove();
|
|
tabNodesRef.current.delete(id);
|
|
}
|
|
}
|
|
|
|
for (const tab of tabs) {
|
|
const isTerminal = !!getTabType(tab.type)?.ownBackground;
|
|
const node = getTabNode(tab.id, isTerminal);
|
|
const paneId = paneIdByTabId.get(tab.id);
|
|
const paneEl = paneId ? paneElsRef.current.get(paneId) : undefined;
|
|
const inPane = !!paneId;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
|
|
if (inPane && paneEl) {
|
|
if (node.parentElement !== paneEl) paneEl.appendChild(node);
|
|
node.style.visibility = "visible";
|
|
node.style.pointerEvents = "auto";
|
|
node.style.display = "";
|
|
node.style.zIndex = "";
|
|
node.style.contentVisibility = "";
|
|
} else {
|
|
if (node.parentElement !== normalView) normalView.appendChild(node);
|
|
if (isTerminal) {
|
|
node.style.display = "";
|
|
node.style.visibility = activeInline ? "visible" : "hidden";
|
|
node.style.pointerEvents = activeInline ? "auto" : "none";
|
|
node.style.zIndex = activeInline ? "1" : "0";
|
|
// xterm renders to a <canvas>; visibility:hidden alone can still let
|
|
// a stale composited frame flash through for a tick when switching
|
|
// to/from a non-terminal tab. content-visibility:hidden fully skips
|
|
// painting the subtree while keeping its layout box intact, so
|
|
// fitAddon.fit() still sees correct dimensions once it's shown again.
|
|
node.style.contentVisibility = activeInline ? "" : "hidden";
|
|
} else {
|
|
// Plays a quick opacity fade-in on the tab that just became active.
|
|
// Only play it on an actual switch into this tab -- gating on the
|
|
// class alone replayed the animation on every unrelated re-render
|
|
// that happened while the tab was still active (any render after
|
|
// animationend had stripped the class), which looked like the
|
|
// panel kept growing for up to a second after switching.
|
|
if (activeInline && lastAnimatedTabIdRef.current !== tab.id) {
|
|
lastAnimatedTabIdRef.current = tab.id;
|
|
node.classList.remove("motion-workspace-enter");
|
|
// Force a reflow so re-adding the class restarts the animation
|
|
// instead of no-oping because it was already removed this tick.
|
|
void node.offsetWidth;
|
|
node.classList.add("motion-workspace-enter");
|
|
node.addEventListener(
|
|
"animationend",
|
|
() => node.classList.remove("motion-workspace-enter"),
|
|
{ once: true },
|
|
);
|
|
} else if (!activeInline) {
|
|
node.classList.remove("motion-workspace-enter");
|
|
if (lastAnimatedTabIdRef.current === tab.id) {
|
|
lastAnimatedTabIdRef.current = null;
|
|
}
|
|
}
|
|
node.style.visibility = "";
|
|
node.style.pointerEvents = "";
|
|
node.style.zIndex = activeInline ? "2" : "";
|
|
node.style.display = activeInline ? "" : "none";
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget);
|
|
const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId);
|
|
|
|
function reorderTopLevelTabs(reordered: Tab[]) {
|
|
setTabs((prev) => [
|
|
...reordered,
|
|
...prev.filter((tab) => tab.parentSplitTabId),
|
|
]);
|
|
}
|
|
|
|
// What command-target panels act on. Falls back to the remembered
|
|
// terminal when the active tab isn't one, and drops it once it's closed.
|
|
const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId)
|
|
? workingTabId
|
|
: terminalTabs.some((t) => t.id === lastTerminalTabId)
|
|
? lastTerminalTabId
|
|
: "";
|
|
|
|
/**
|
|
* Sidebar panel content, shared between the desktop sidebar, the mobile
|
|
* sheet and the right dock. Takes the view rather than reading railView so
|
|
* both docks can render from the same code.
|
|
*
|
|
* `owned` marks the dock responsible for the panels that stay mounted while
|
|
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
|
|
* two live instances fight over the shared editing state.
|
|
*/
|
|
// The param deliberately shadows the outer railView so the body reads the
|
|
// same whichever dock is rendering.
|
|
|
|
/**
|
|
* What plugins may ask of the shell. Rebuilt every render so it always
|
|
* closes over current state, and published to the plugin runtime.
|
|
*/
|
|
const 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) =>
|
|
requestManage({ kind: "host", hostId: null, draft }),
|
|
saveQuickConnect: saveQuickConnectHost,
|
|
confirm: (options) =>
|
|
shellConfirmRef.current
|
|
? shellConfirmRef.current(options)
|
|
: Promise.resolve(false),
|
|
};
|
|
|
|
// 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),
|
|
confirm: (...args) => shellImplRef.current.confirm!(...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]);
|
|
|
|
// Every panel gets its own surface, so its inline views and confirms stay
|
|
// inside it. Only the left dock widens for them.
|
|
const panelScope = (
|
|
view: string,
|
|
owned: boolean,
|
|
shown: boolean,
|
|
children: React.ReactNode,
|
|
className = "",
|
|
) => (
|
|
<SurfaceScope
|
|
key={view}
|
|
kind="panel"
|
|
onEditingChange={owned ? scopeEditingSetter(view) : undefined}
|
|
className={`${shown ? "" : "hidden"} ${className}`}
|
|
>
|
|
{children}
|
|
</SurfaceScope>
|
|
);
|
|
|
|
const renderSidebarPanels = (railView: RailView, owned = true) => (
|
|
<Suspense fallback={<SidebarPanelFallback />}>
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{owned && (
|
|
<>
|
|
{panelScope(
|
|
"hosts",
|
|
owned,
|
|
railView === "hosts",
|
|
<HostsPanel
|
|
onOpenTab={(host, type, options) => {
|
|
connectHost(host, type, options);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onEditHost={editHostInManager}
|
|
hostTree={realHostTree ?? undefined}
|
|
loading={hostsLoading}
|
|
onEditingChange={panelEditingSetter("hosts")}
|
|
active={railView === "hosts"}
|
|
/>,
|
|
)}
|
|
{panelScope(
|
|
"credentials",
|
|
owned,
|
|
railView === "credentials",
|
|
<CredentialsPanel
|
|
onEditingChange={panelEditingSetter("credentials")}
|
|
active={railView === "credentials"}
|
|
/>,
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{railView === "quick-connect" &&
|
|
panelScope(
|
|
"quick-connect",
|
|
owned,
|
|
true,
|
|
<QuickConnectPanel
|
|
onConnect={(host, type) => {
|
|
openTab(host, type);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>,
|
|
)}
|
|
|
|
{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 panelScope(
|
|
panel.id,
|
|
owned,
|
|
shown,
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
<Panel
|
|
targetTab={terminalTabs.find(
|
|
(tab) => tab.id === targetTerminalTabId,
|
|
)}
|
|
active={shown}
|
|
shell={panelShell}
|
|
setEditing={
|
|
owned ? panelEditingSetter(panel.id) : () => undefined
|
|
}
|
|
activeTabType={
|
|
tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined
|
|
}
|
|
placement={owned ? "left" : "right"}
|
|
/>
|
|
</div>,
|
|
);
|
|
})}
|
|
|
|
{!isCoreRailView(railView) && !getPanel(railView) && (
|
|
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
|
|
)}
|
|
|
|
{railView === "connections" &&
|
|
panelScope(
|
|
"connections",
|
|
owned,
|
|
true,
|
|
<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;
|
|
if (!host && !getTabType(record.tabType)?.hostless) return;
|
|
setBackgroundTabRecords((prev) =>
|
|
prev.filter((r) => r.id !== record.id),
|
|
);
|
|
if (host) {
|
|
const effectiveSessionId =
|
|
restoredSessionId ?? record.backendSessionId ?? null;
|
|
openTab(host, record.tabType as TabType, {
|
|
instanceId: record.id,
|
|
restoredSessionId: effectiveSessionId,
|
|
savedLabel: record.label,
|
|
});
|
|
} else {
|
|
openSingletonTab(record.tabType as TabType);
|
|
}
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onForgetBackground={(recordId) => {
|
|
setBackgroundTabRecords((prev) =>
|
|
prev.filter((r) => r.id !== recordId),
|
|
);
|
|
}}
|
|
onRenameTab={renameTab}
|
|
onReorderTabs={setTabs}
|
|
/>
|
|
</div>,
|
|
)}
|
|
|
|
{railView === "sync" &&
|
|
panelScope(
|
|
"sync",
|
|
owned,
|
|
true,
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SyncPanel />
|
|
</div>,
|
|
)}
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
|
|
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)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const sidebarPanelContent = renderSidebarPanels(railView);
|
|
|
|
const settingsTabProps = {
|
|
username,
|
|
isAdmin: showAdminUI,
|
|
onLogout,
|
|
userPrefs,
|
|
onPrefsChange: (updates: Partial<UserPreferences>) =>
|
|
setUserPrefs((current) => ({ ...current, ...updates })),
|
|
onOpenHostTab: (host: Host) => connectHost(host),
|
|
};
|
|
|
|
const leftEditing: EditingWidth = (() => {
|
|
const values = [panelEditing[railView], scopeEditing[railView]];
|
|
if (values.includes("wide")) return "wide";
|
|
return values.some(Boolean);
|
|
})();
|
|
|
|
const dockProps = (view: RailView) => ({
|
|
title: sidebarTitle(view),
|
|
onOpenAsTab: isMobile
|
|
? undefined
|
|
: view === "hosts" || view === "credentials"
|
|
? () => requestManage({ kind: "browse", mode: view as ManageMode })
|
|
: promotableIds().includes(view)
|
|
? () => openSingletonTab(view as TabType)
|
|
: undefined,
|
|
});
|
|
|
|
return (
|
|
<ServerStatusProvider isAuthenticated={!!username}>
|
|
<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 */}
|
|
<AppRail
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
username={username}
|
|
isAdmin={showAdminUI}
|
|
pluginsSettled={pluginsSettled}
|
|
onRailClick={handleRailClick}
|
|
onOpenTab={openSingletonTab}
|
|
onOpenInRightDock={openInRightDock}
|
|
onOpenSettings={() => openSingletonTab("settings")}
|
|
onOpenNavigationSettings={() =>
|
|
openSingletonTab("settings", undefined, undefined, {
|
|
section: "appearance",
|
|
reveal: "settings-navigation",
|
|
revealAt: Date.now(),
|
|
})
|
|
}
|
|
onOpenPalette={() => setCommandPaletteOpen(true)}
|
|
onLogout={onLogout}
|
|
/>
|
|
|
|
{/* Desktop: inline resizable sidebar */}
|
|
{!isMobile && (
|
|
<DockPanel
|
|
side="left"
|
|
{...dockProps(railView)}
|
|
open={sidebarOpen}
|
|
width={sidebarWidth}
|
|
editing={leftEditing}
|
|
dragging={sidebarDragging}
|
|
onResizeStart={onSidebarMouseDown}
|
|
onClose={() => setSidebarOpen(false)}
|
|
onMoveToRightDock={
|
|
rightDockableIds().includes(railView)
|
|
? () => openInRightDock(railView)
|
|
: undefined
|
|
}
|
|
onResetWidth={() => setSidebarWidth(DOCK_DEFAULT_WIDTH)}
|
|
>
|
|
{sidebarPanelContent}
|
|
</DockPanel>
|
|
)}
|
|
|
|
{/* Mobile: sidebar as overlay sheet */}
|
|
{isMobile && (
|
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
<SheetContent
|
|
side="left"
|
|
showCloseButton={false}
|
|
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${leftEditing ? "w-screen" : "w-[min(85vw,360px)]"}`}
|
|
style={{ height: "100dvh" }}
|
|
>
|
|
<DockPanel
|
|
side="left"
|
|
{...dockProps(railView)}
|
|
open
|
|
fill
|
|
width={sidebarWidth}
|
|
onClose={() => setSidebarOpen(false)}
|
|
>
|
|
{sidebarPanelContent}
|
|
</DockPanel>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)}
|
|
|
|
{/* Main content area */}
|
|
<div
|
|
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
|
>
|
|
{!isMobile && !sidebarOpen && (
|
|
<DockReopenStrip onClick={() => setSidebarOpen(true)} />
|
|
)}
|
|
<SurfaceScope kind="tab" className="overflow-hidden">
|
|
<ShellConfirmCapture confirmRef={shellConfirmRef} />
|
|
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
|
<TabBar
|
|
tabs={topLevelTabs}
|
|
activeTabId={activeTabId}
|
|
splits={summarizeSplits(tabs, MAX_PANES)}
|
|
activeSplitFull={
|
|
activeSplit ? !canAddPane(activeSplit.split) : false
|
|
}
|
|
onSetActiveTab={setActiveTabId}
|
|
onCloseTab={closeTab}
|
|
onRefreshTab={refreshTab}
|
|
onReconnectDisconnected={reconnectAllDisconnected}
|
|
onReorderTabs={reorderTopLevelTabs}
|
|
onSplitAction={handleTabSplitAction}
|
|
onRenameTab={renameTab}
|
|
isAppFullscreen={isAppFullscreen}
|
|
onToggleAppFullscreen={toggleAppFullscreen}
|
|
rightDockOpen={rightRailView !== null}
|
|
onToggleRightDock={isMobile ? undefined : toggleRightDock}
|
|
showTabNumbers={showTabNumbers}
|
|
/>
|
|
<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">
|
|
<SplitView
|
|
splitTab={activeSplit}
|
|
tabs={tabs}
|
|
isMobile={isMobile}
|
|
actions={splitViewActions(activeSplit.id)}
|
|
onPaneContentRef={onPaneContentRef}
|
|
renderEmptyPane={(paneId, paneIndex) =>
|
|
renderEmptyPane(activeSplit.id, paneId, paneIndex)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<SplitDropOverlay containerRef={mainAreaRef} />
|
|
|
|
{/* 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.
|
|
Hidden while a split is active. */}
|
|
<div
|
|
ref={normalViewRef}
|
|
className="absolute inset-0"
|
|
style={{ display: activeSplit ? "none" : undefined }}
|
|
>
|
|
{tabsByPortalOrder.map((tab) => {
|
|
const tabNode = getTabNode(
|
|
tab.id,
|
|
!!getTabType(tab.type)?.ownBackground,
|
|
);
|
|
const paneId = paneIdByTabId.get(tab.id);
|
|
const inPane = !!paneId;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
const isFocusedPane = inPane
|
|
? paneId === focusedPaneId
|
|
: activeInline;
|
|
return createPortal(
|
|
<SurfaceScope
|
|
kind="tab"
|
|
className="h-full w-full"
|
|
onClose={
|
|
tab.id === "dashboard"
|
|
? undefined
|
|
: () => closeTab(tab.id)
|
|
}
|
|
>
|
|
{renderTabContent(tab, {
|
|
shell: shellCallbacks,
|
|
settings: settingsTabProps,
|
|
panelTargetTab: terminalTabs.find(
|
|
(t) => t.id === targetTerminalTabId,
|
|
),
|
|
isVisible: inPane || activeInline,
|
|
isFocusedPane,
|
|
inSplit: inPane,
|
|
})}
|
|
</SurfaceScope>,
|
|
tabNode,
|
|
tab.id,
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SurfaceScope>
|
|
|
|
{/* Bottom nav bar — mobile only */}
|
|
<MobileBar
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
username={username}
|
|
onRailClick={handleRailClick}
|
|
onOpenTab={(type) => openSingletonTab(type)}
|
|
onOpenSettings={() => openSingletonTab("settings")}
|
|
onOpenPalette={() => setCommandPaletteOpen(true)}
|
|
onLogout={() => onLogout({ manual: true })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right dock — desktop only, holds a second reference panel */}
|
|
{!isMobile && rightRailView && (
|
|
<DockPanel
|
|
side="right"
|
|
{...dockProps(rightRailView)}
|
|
open
|
|
width={rightSidebarWidth}
|
|
dragging={rightSidebarDragging}
|
|
onResizeStart={onRightSidebarMouseDown}
|
|
onClose={() => setRightRailView(null)}
|
|
onResetWidth={() => setRightSidebarWidth(DOCK_DEFAULT_WIDTH)}
|
|
>
|
|
{renderSidebarPanels(rightRailView, false)}
|
|
</DockPanel>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ComponentSlot slotId="shell.overlay" />
|
|
|
|
{commandPaletteOpen && (
|
|
<Suspense fallback={null}>
|
|
<CommandPalette
|
|
isOpen={commandPaletteOpen}
|
|
setIsOpen={setCommandPaletteOpen}
|
|
hosts={allHosts}
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={activeTabId}
|
|
onOpenPanel={(view) => handleRailClick(view as RailView)}
|
|
onEditHost={editHostInManager}
|
|
onOpenSettings={(section) =>
|
|
openSingletonTab(
|
|
"settings",
|
|
undefined,
|
|
undefined,
|
|
section ? { section } : undefined,
|
|
)
|
|
}
|
|
onOpenTab={(type, label, pendingEvent) => {
|
|
if (
|
|
[
|
|
"dashboard",
|
|
"host-manager",
|
|
"user-profile",
|
|
"admin-settings",
|
|
"plugins",
|
|
].includes(type)
|
|
) {
|
|
openSingletonTab(type, pendingEvent);
|
|
} else if (getTabType(type)?.multiInstance) {
|
|
openMultiInstanceTab(type);
|
|
} else if (getTabType(type)?.singleton) {
|
|
// A singleton plugin tab, optionally preselecting a host.
|
|
openSingletonTab(
|
|
type,
|
|
undefined,
|
|
label ? allHosts.find((h) => h.name === label) : undefined,
|
|
);
|
|
} else if (label) {
|
|
const host = allHosts.find((h) => h.name === label);
|
|
if (host) openTab(host, type);
|
|
}
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
<OnboardingHost onOpenChange={setShowOnboarding} />
|
|
<DonationReminderModal
|
|
open={showDonationModal && !showOnboarding}
|
|
onDismiss={handleDismissDonationModal}
|
|
/>
|
|
</ServerStatusProvider>
|
|
);
|
|
}
|