feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+72 -446
View File
@@ -1,24 +1,16 @@
/* eslint-disable react-refresh/only-export-components */
import {
Box,
Boxes,
Braces,
FolderSearch,
HardDrive,
LayoutDashboard,
LayoutGrid,
LayoutPanelLeft,
MessagesSquare,
Monitor,
MousePointerClick,
Network,
ArrowLeftRight,
Server,
Settings,
Terminal,
Usb,
User,
Activity,
TerminalSquare,
Layers, // --- tmux-monitor ---
Clock,
@@ -26,43 +18,25 @@ import {
Hammer,
Play,
ScrollText,
Sparkles,
Presentation,
Workflow,
Globe,
} from "lucide-react";
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import type { SerialHandle } from "@/features/serial/serial-types";
import type {
TerminalHandle,
TerminalHostConfig,
} from "@/features/terminal/Terminal";
import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/src/frontend/GuacamoleApp";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Tab, TabType, Host } from "@/types/ui-types";
import type { Tab, TabType } from "@/types/ui-types";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import {
isQuickConnectHost,
quickConnectGuacHost,
} from "@/sidebar/quick-connect-host";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
getTabType,
type TabShellCallbacks,
type TabTypeDef,
} from "./tab-registry";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// Heavy tab surfaces — keep out of the AppShell critical path.
const CommandHistoryProvider = lazy(() =>
import("@/features/terminal/command-history/CommandHistoryContext").then(
(m) => ({ default: m.CommandHistoryProvider }),
),
);
const loadTerminalFeature = () =>
import("@/features/terminal/Terminal").then((m) => ({
default: m.Terminal,
}));
const TerminalFeature = lazy(loadTerminalFeature);
const CollabRoomTab = lazy(() =>
import("@/features/collab/CollabRoomTab").then((m) => ({
default: m.CollabRoomTab,
@@ -73,52 +47,16 @@ const LocalTerminal = lazy(() =>
default: m.LocalTerminal,
})),
);
const MobileTerminalKeyboard = lazy(() =>
import("@/features/terminal/MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
const loadFileManager = () =>
import("@/features/file-manager/FileManager").then((m) => ({
default: m.FileManager,
}));
const FileManager = lazy(loadFileManager);
const loadDockerManager = () =>
import("../../../plugins/docker/src/frontend/DockerManager").then((m) => ({
default: m.DockerManager,
}));
const DockerManager = lazy(loadDockerManager);
const loadWebEndpointTab = () =>
import("../../../plugins/web-endpoint/src/frontend/WebEndpointTab").then(
(m) => ({
default: m.WebEndpointTab,
}),
);
const WebEndpointTab = lazy(loadWebEndpointTab);
const loadHostMetricsTab = () =>
import("../../../plugins/host-metrics/src/frontend/HostMetricsTab").then(
(m) => ({
default: m.HostMetricsTab,
}),
);
const HostMetricsTab = lazy(loadHostMetricsTab);
const loadProxmoxStatsTab = () =>
import("@/features/proxmox-stats/ProxmoxStatsTab").then((m) => ({
default: m.ProxmoxStatsTab,
}));
const ProxmoxStatsTab = lazy(loadProxmoxStatsTab);
const loadTmuxMonitor = () =>
import("@/features/tmux-monitor/TmuxMonitor").then((m) => ({
default: m.TmuxMonitor,
}));
const TmuxMonitor = lazy(loadTmuxMonitor);
const loadGuacamoleApp = () =>
import("../../../plugins/remote-desktop/src/frontend/GuacamoleApp").then(
(m) => ({
default: m.default,
}),
);
const GuacamoleApp = lazy(loadGuacamoleApp);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
@@ -139,26 +77,11 @@ const SftpTransferTab = lazy(() =>
default: m.SftpTransferTab,
})),
);
const NetworkGraphCard = lazy(() =>
import("../../../plugins/network-topology/src/frontend/NetworkGraphCard").then(
(m) => ({
default: m.NetworkGraphCard,
}),
),
);
const Serial = lazy(() =>
import("@/features/serial/Serial").then((m) => ({
default: m.Serial,
})),
);
const FleetInventoryTab = lazy(() =>
import("../../../plugins/fleets/src/frontend/FleetInventoryTab").then(
(m) => ({
default: m.FleetInventoryTab,
}),
),
);
// Rail panels promoted to full tabs.
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
@@ -180,72 +103,15 @@ const HistoryPanel = lazy(() =>
const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const AutomationsPanel = lazy(() =>
import("../../../plugins/automations/src/frontend/AutomationsPanel").then(
(m) => ({
default: m.AutomationsPanel,
}),
),
);
const AiPanel = lazy(() =>
import("@/features/ai/AiPanel").then((m) => ({
default: m.AiPanel,
})),
);
const tabSurfaceLoaders: Partial<Record<TabType, () => Promise<unknown>>> = {
terminal: loadTerminalFeature,
files: loadFileManager,
docker: loadDockerManager,
"host-metrics": loadHostMetricsTab,
"proxmox-stats": loadProxmoxStatsTab,
tmux_monitor: loadTmuxMonitor,
tunnel: loadTunnelTab,
rdp: loadGuacamoleApp,
vnc: loadGuacamoleApp,
telnet: loadGuacamoleApp,
};
/**
* A plugin-contributed tab surface. `component` is the loader's resolved
* default export, rendered with the same Tab it would have received from the
* switch in renderTabContent -- plugins get the raw tab record and decide
* for themselves what they need out of it.
*/
export type PluginTabComponent = React.ComponentType<{ tab: Tab }>;
export type PluginTabLoader = () => Promise<{ default: PluginTabComponent }>;
interface PluginTabEntry {
loader: PluginTabLoader;
icon?: React.ReactNode;
// Built lazily on first render and cached here, so every render of the same
// plugin tab reuses one lazy component instead of remounting it each time.
LazyComponent?: PluginTabComponent;
}
/**
* Runtime registry for plugin tab ids that don't exist in the built-in
* TabType union. Registered once a plugin loads, looked up by tabIcon and
* renderTabContent instead of a switch case.
*/
const registeredTabComponents = new Map<string, PluginTabEntry>();
export function registerTabComponent(
id: string,
loader: PluginTabLoader,
icon?: React.ReactNode,
): void {
registeredTabComponents.set(id, { loader, icon });
}
export function unregisterTabComponent(id: string): void {
registeredTabComponents.delete(id);
}
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
const loader = tabSurfaceLoaders[type];
const loader = tabSurfaceLoaders[type] ?? getTabType(type)?.preload;
if (loader) runAdaptiveBackgroundTask("module", `tab:${type}`, loader);
}
@@ -253,54 +119,6 @@ export function markTabSurfaceUsed(type: TabType): void {
markAdaptiveResourceUsed("module", `tab:${type}`);
}
function hostToSSHHost(h: Host): SSHHost {
return {
id: parseInt(h.id, 10),
name: h.name,
ip: h.ip,
port: h.port,
username: h.username,
folder: h.folder ?? "",
tags: h.tags ?? [],
pin: h.pin ?? false,
authType: h.authType,
password: h.password,
hasPassword: h.hasPassword,
key: h.key,
keyPassword: h.keyPassword,
hasKey: h.hasKey,
hasKeyPassword: h.hasKeyPassword,
keyType: h.keyType,
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
terminalConfig: h.terminalConfig,
hasSudoPassword: h.hasSudoPassword,
enableTerminal: h.enableTerminal ?? false,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableAiAssistant: h.enableAiAssistant ?? false,
dockerConfig: h.dockerConfig ?? null,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: h.webUiConfig ?? { endpoints: [] },
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
defaultPath: h.defaultPath ?? "",
tunnelConnections: [],
connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
isShared: h.isShared ?? false,
// Carries the host's identity to a delegated backend. Without it the
// remote side resolves our local row id against its own table.
syncId: h.syncId ?? null,
createdAt: "",
updatedAt: "",
} as unknown as SSHHost;
}
function EmptyState({
icon: Icon,
messageKey,
@@ -352,20 +170,8 @@ export function tabIcon(type: TabType) {
switch (type) {
case "dashboard":
return <LayoutDashboard className="size-3.5" />;
case "terminal":
return <Terminal className="size-3.5" />;
case "local-terminal":
return <TerminalSquare className="size-3.5" />;
case "rdp":
return <Monitor className="size-3.5" />;
case "vnc":
return <MousePointerClick className="size-3.5" />;
case "telnet":
return <MessagesSquare className="size-3.5" />;
case "host-metrics":
return <Server className="size-3.5" />;
case "proxmox-stats":
return <HardDrive className="size-3.5" />;
case "files":
return <FolderSearch className="size-3.5" />;
case "host-manager":
@@ -374,16 +180,10 @@ export function tabIcon(type: TabType) {
return <User className="size-3.5" />;
case "admin-settings":
return <Settings className="size-3.5" />;
case "docker":
return <Box className="size-3.5" />;
case "web-endpoint":
return <Globe className="size-3.5" />;
case "tunnel":
return <Network className="size-3.5" />;
case "sftp":
return <ArrowLeftRight className="size-3.5" />;
case "network_graph":
return <Network className="size-3.5" />;
// --- tmux-monitor ---
case "tmux_monitor":
return <Layers className="size-3.5" />;
@@ -391,8 +191,6 @@ export function tabIcon(type: TabType) {
return <Usb className="size-3.5" />;
case "homepage":
return <LayoutGrid className="size-3.5" />;
case "fleet-inventory":
return <Boxes className="size-3.5" />;
case "collab":
return <Presentation className="size-3.5" />;
case "termix-id":
@@ -407,90 +205,60 @@ export function tabIcon(type: TabType) {
return <Clock className="size-3.5" />;
case "ssh-tools":
return <Hammer className="size-3.5" />;
case "automations":
return <Workflow className="size-3.5" />;
case "ai":
return <Sparkles className="size-3.5" />;
case "split-screen":
return <LayoutPanelLeft className="size-3.5" />;
default:
return registeredTabComponents.get(type)?.icon;
default: {
const Icon = getTabType(type)?.icon;
return Icon ? <Icon className="size-3.5" /> : null;
}
}
}
function TerminalTabContent({
/**
* A plugin's tab: its registered component with the generic render props.
* Unregistered types get the ownership placeholder in renderTabContent.
*/
function RegisteredTab({
def,
tab,
host,
label,
isVisible,
isFocusedPane,
onCloseTab,
onRenameTab,
onOpenFileInEditor,
onOpenFileManager,
onOpenTab,
onSaveQuickConnect,
shell,
}: {
def: TabTypeDef;
tab: Tab;
host: Host;
label: string;
isVisible: boolean;
isFocusedPane: boolean;
onCloseTab?: (id: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onOpenFileInEditor?: (filePath: string) => void;
onOpenFileManager?: (path?: string) => void;
onOpenTab?: (type: TabType) => void;
onSaveQuickConnect?: () => Promise<void>;
shell: TabShellCallbacks;
}) {
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
const { host, label } = tab;
if (def.requiresHost && !host) {
return (
<EmptyState
icon={Server}
messageKey={def.noHostMessageKey ?? "hosts.noHostSelected"}
/>
);
}
const Component = def.component;
const content = (
<Component
tab={tab}
host={host}
sshHost={
host
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
: undefined
}
label={label}
isVisible={isVisible}
isFocusedPane={isFocusedPane}
handleRef={tab.terminalRef as React.Ref<unknown>}
shell={shell}
/>
);
return withTabSuspense(
<CommandHistoryProvider>
<div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0">
<TerminalFeature
ref={tab.terminalRef as React.Ref<TerminalHandle>}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId: tab.joinSharedSessionId ?? null,
joinShareId: tab.joinShareId ?? null,
} as TerminalHostConfig
}
isVisible={isVisible}
initialPath={tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => onCloseTab?.(tab.id)}
onTitleChange={
onRenameTab && host.terminalConfig?.useSSHTitle
? (title) => onRenameTab(tab.id, title)
: undefined
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={onOpenFileInEditor}
onOpenFileManager={onOpenFileManager}
isQuickConnect={host.id.startsWith("quick-connect-")}
onSaveQuickConnect={onSaveQuickConnect}
host={host}
onOpenTab={onOpenTab}
isFocusedPane={isFocusedPane}
/>
</div>
{isMobile && (
<MobileTerminalKeyboard
terminalRef={
tab.terminalRef as React.RefObject<TerminalHandle | null>
}
/>
)}
</div>
</CommandHistoryProvider>,
def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content,
);
}
@@ -505,64 +273,27 @@ export type PromotedPanelProps = {
storageMode?: "local" | "cloud";
};
export function renderTabContent(
tab: Tab,
onOpenSingletonTab?: (type: TabType) => void,
onOpenTab?: (host: Host, type: TabType) => void,
onCloseTab?: (id: string) => void,
isVisible = true,
onOpenFileInEditor?: (host: Host, filePath: string) => void,
onOpenFileManager?: (host: Host, path?: string) => void,
onOpenTerminalTab?: (host: Host, path?: string) => void,
onRenameTab?: (tabId: string, newLabel: string) => void,
onSaveQuickConnect?: (tab: Tab, host: Host) => Promise<void>,
isFocusedPane = true,
panelProps?: PromotedPanelProps,
) {
export interface TabRenderContext {
shell: TabShellCallbacks;
isVisible?: boolean;
isFocusedPane?: boolean;
panelProps?: PromotedPanelProps;
}
export function renderTabContent(tab: Tab, context: TabRenderContext) {
const { shell, isVisible = true, isFocusedPane = true, panelProps } = context;
const { host, label } = tab;
switch (tab.type) {
case "dashboard":
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={onOpenSingletonTab!}
onOpenTab={onOpenTab!}
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
);
case "terminal":
if (!host)
return (
<EmptyState
icon={TerminalSquare}
messageKey="terminal.noHostSelected"
/>
);
return (
<TerminalTabContent
tab={tab}
host={host}
label={label}
isVisible={isVisible}
isFocusedPane={isFocusedPane}
onCloseTab={onCloseTab}
onRenameTab={onRenameTab}
onOpenFileInEditor={
onOpenFileInEditor
? (fp) => onOpenFileInEditor(host, fp)
: undefined
}
onOpenFileManager={
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
}
onOpenTab={onOpenTab ? (type) => onOpenTab(host, type) : undefined}
onSaveQuickConnect={
onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined
}
/>
);
case "local-terminal":
return withTabSuspense(
<LocalTerminal instanceId={tab.instanceId} isVisible={isVisible} />,
@@ -582,69 +313,7 @@ export function renderTabContent(
initialFilePath={tab.initialFilePath}
initialPath={tab.initialPath}
isVisible={isVisible}
onOpenTerminalTab={
onOpenTerminalTab
? (path) => onOpenTerminalTab(host, path)
: undefined
}
/>,
);
case "web-endpoint":
if (!host)
return (
<EmptyState icon={Globe} messageKey="webEndpoint.noHostSelected" />
);
// Passed the endpoint ID, not the endpoint object: the tab resolves it
// against the host on every render, so an endpoint deleted while its tab
// is open shows a plain message instead of throwing.
return withTabSuspense(
<WebEndpointTab host={host} endpointId={tab.endpointId} />,
);
case "docker":
if (!host)
return <EmptyState icon={Box} messageKey="docker.noHostSelected" />;
return withTabSuspense(
<DockerManager
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>,
);
case "host-metrics":
if (!host)
return (
<EmptyState icon={Activity} messageKey="hostMetrics.noHostSelected" />
);
return withTabSuspense(
<HostMetricsTab
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>,
);
case "proxmox-stats":
if (!host)
return (
<EmptyState
icon={HardDrive}
messageKey="proxmoxStats.noHostSelected"
/>
);
return withTabSuspense(
<ProxmoxStatsTab
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
onOpenTerminalTab={(path) => shell.openTerminalTab(host, path)}
/>,
);
@@ -656,34 +325,6 @@ export function renderTabContent(
case "sftp":
return withTabSuspense(<SftpTransferTab />);
case "rdp":
case "vnc":
case "telnet":
if (!host)
return (
<EmptyState
icon={Monitor}
messageKey="remoteDesktop.noHostSelected"
/>
);
return withTabSuspense(
<GuacamoleApp
ref={tab.terminalRef as React.Ref<GuacamoleAppHandle>}
hostId={host.id}
tabId={tab.id}
protocol={tab.type as "rdp" | "vnc" | "telnet"}
isVisible={isVisible}
quickConnectHost={
isQuickConnectHost(host) ? quickConnectGuacHost(host) : undefined
}
/>,
);
case "network_graph":
return withTabSuspense(
<NetworkGraphCard embedded={false} isVisible={isVisible} />,
);
// --- tmux-monitor ---
case "tmux_monitor":
return withTabSuspense(
@@ -708,11 +349,6 @@ export function renderTabContent(
case "homepage":
return withTabSuspense(<HomepageCanvas />);
case "fleet-inventory":
return withTabSuspense(
<FleetInventoryTab fleetId={tab.fleetId} isVisible={isVisible} />,
);
case "collab":
return withTabSuspense(
<CollabRoomTab roomId={tab.collabRoomId} isVisible={isVisible} />,
@@ -732,20 +368,6 @@ export function renderTabContent(
</PanelTabFrame>,
);
case "automations":
return withTabSuspense(
<PanelTabFrame>
<AutomationsPanel active={isVisible} />
</PanelTabFrame>,
);
case "ai":
return withTabSuspense(
<PanelTabFrame>
<AiPanel />
</PanelTabFrame>,
);
case "split-screen":
return null;
@@ -797,15 +419,19 @@ export function renderTabContent(
return null;
default: {
const entry = registeredTabComponents.get(tab.type);
if (!entry) return null;
if (!entry.LazyComponent) {
entry.LazyComponent = lazy(() =>
entry.loader().then((m) => ({ default: m.default })),
) as unknown as PluginTabComponent;
const def = getTabType(tab.type);
if (!def) {
return <PluginViewPlaceholder kind="tab" viewId={tab.type} />;
}
const PluginTab = entry.LazyComponent;
return withTabSuspense(<PluginTab tab={tab} />);
return (
<RegisteredTab
def={def}
tab={tab}
isVisible={isVisible}
isFocusedPane={isFocusedPane}
shell={shell}
/>
);
}
}
}