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

+35 -1
View File
@@ -22,6 +22,8 @@ interface ActionSlotProps {
) => React.ReactNode;
/** Icon-only, for toolbars that collapse their labels. */
hideLabels?: boolean;
/** Passed to each contribution's `when`. */
when?: Record<string, unknown>;
}
/**
@@ -39,9 +41,12 @@ export function ActionSlot({
enabled = true,
renderItem,
hideLabels,
when,
}: ActionSlotProps) {
const { t } = useTranslation();
const contributions = useActionSlot(slotId);
const contributions = useActionSlot(slotId, when).filter(
(contribution) => contribution.kind !== "component",
);
if (!enabled || contributions.length === 0) return null;
@@ -80,3 +85,32 @@ export function ActionSlot({
</>
);
}
/**
* Renders the component contributions in a slot, each with the owner's
* props. Same permission and `when` filtering as buttons, and the same
* nothing-at-all output when there is nothing to show.
*/
export function ComponentSlot({
slotId,
props,
when,
}: {
slotId: string;
props?: Record<string, unknown>;
when?: Record<string, unknown>;
}) {
const contributions = useActionSlot(slotId, when).filter(
(contribution) =>
contribution.kind === "component" && contribution.component,
);
if (contributions.length === 0) return null;
return (
<>
{contributions.map((contribution) => {
const Contributed = contribution.component!;
return <Contributed key={contribution.actionId} {...(props ?? {})} />;
})}
</>
);
}
+91 -115
View File
@@ -14,22 +14,16 @@ import {
Server,
Settings,
Terminal,
FolderOpen,
FolderSearch,
Box,
Globe,
HardDrive,
Plus,
MessagesSquare,
LifeBuoy,
Search,
Activity,
Network,
User,
KeyRound,
Layers, // --- tmux-monitor ---
Monitor,
MousePointerClick,
Clock,
Folder,
Pencil,
@@ -43,10 +37,17 @@ import {
} from "@/main-axios";
import type { Host, TabType, Tab, Snippet } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
import { RAIL_ITEMS, RAIL_UTILITY_ITEMS } from "@/sidebar/rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
import { isTabTypeAvailable } from "@/shell/pluginLoader";
import {
defaultConnectAction,
hostActionsFor,
runHostAction,
useHostActions,
} from "@/sidebar/host-contributions";
import { paletteEntriesFor, usePaletteEntries } from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { shell } from "@/plugin-host/shell-bridge";
interface CommandPaletteProps {
isOpen: boolean;
@@ -59,47 +60,18 @@ interface CommandPaletteProps {
onOpenPanel?: (view: string) => void;
}
const ACTIVITY_ICONS: Record<string, React.ReactNode> = {
terminal: <Terminal className="size-3.5" />,
file_manager: <FolderOpen className="size-3.5" />,
server_stats: <Activity className="size-3.5" />,
tunnel: <Network className="size-3.5" />,
docker: <Box className="size-3.5" />,
telnet: <MessagesSquare className="size-3.5" />,
vnc: <MousePointerClick className="size-3.5" />,
rdp: <Monitor className="size-3.5" />,
};
const ACTIVITY_TAB_TYPE: Record<string, TabType> = {
terminal: "terminal",
file_manager: "files",
server_stats: "host-metrics",
tunnel: "tunnel",
docker: "docker",
telnet: "telnet",
vnc: "vnc",
rdp: "rdp",
};
/** Core per-host tools. Plugins add theirs as host actions. */
function getSshActions(host: Host): {
type: TabType;
icon: React.ElementType;
label: string;
}[] {
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
return [
host.enableTerminal !== false &&
isTabTypeAvailable("terminal") && {
type: "terminal",
icon: Terminal,
label: "Terminal",
},
host.enableFileManager && {
type: "files",
icon: FolderSearch,
label: "Files",
},
host.enableDocker && { type: "docker", icon: Box, label: "Docker" },
// --- tmux-monitor --- opt-in per host, off by default
host.enableTerminal !== false &&
host.enableTmuxMonitor && {
@@ -108,16 +80,6 @@ function getSshActions(host: Host): {
label: "Tmux Monitor",
},
host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" },
metricsEnabled && {
type: "host-metrics",
icon: Activity,
label: "Host Metrics",
},
host.enableProxmoxStats === true && {
type: "proxmox-stats",
icon: HardDrive,
label: "Proxmox Stats",
},
].filter(Boolean) as {
type: TabType;
icon: React.ElementType;
@@ -135,7 +97,9 @@ export function CommandPalette({
onOpenPanel,
}: CommandPaletteProps) {
const { t } = useTranslation();
const { globallyEnabled: aiGloballyEnabled } = useAiAvailability();
const railItems = useRailItems();
const hostActions = useHostActions();
const paletteEntries = usePaletteEntries();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
@@ -396,9 +360,8 @@ export function CommandPalette({
heading={t("commandPalette.navigation")}
className="px-2"
>
{[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
{[...railItems, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.kind !== "tab")
.filter((item) => item.id !== "ai" || aiGloballyEnabled)
.map((item) => {
const Icon = item.icon;
return (
@@ -471,6 +434,37 @@ export function CommandPalette({
</>
)}
{paletteEntriesFor(paletteEntries, "global").length > 0 && (
<>
<CommandSeparator className="my-2" />
<CommandGroup
heading={t("commandPalette.pluginActions")}
className="px-2"
>
{paletteEntriesFor(paletteEntries, "global").map((entry) => {
const Icon = entry.icon;
return (
<CommandItem
key={`plugin-${entry.id}`}
value={`plugin-${entry.id} ${t(entry.titleKey)} ${(entry.keywords ?? []).join(" ")}`}
onSelect={() => handleAction(() => entry.run(shell))}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
{Icon && (
<Icon className="size-4 text-accent-brand" />
)}
</div>
<span className="text-sm font-semibold flex-1">
{t(entry.titleKey)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{recentActivity.length > 0 && (
<>
<CommandSeparator className="my-2" />
@@ -483,17 +477,18 @@ export function CommandPalette({
key={item.id}
value={`recent-activity-${item.id}`}
onSelect={() =>
handleAction(() =>
onOpenTab(
ACTIVITY_TAB_TYPE[item.type],
item.hostName,
),
)
handleAction(() => {
const target = activityTarget(item.type);
if (target) onOpenTab(target.tab, item.hostName);
})
}
className="group flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-7 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors text-muted-foreground group-hover:text-accent-brand">
{ACTIVITY_ICONS[item.type]}
{(() => {
const Icon = activityTarget(item.type)?.icon;
return Icon ? <Icon className="size-3.5" /> : null;
})()}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
@@ -537,17 +532,11 @@ export function CommandPalette({
value={`host-${host.id}`}
onSelect={() =>
handleAction(() => {
const type = host.enableSsh
? "terminal"
: host.enableRdp && isTabTypeAvailable("rdp")
? "rdp"
: host.enableVnc && isTabTypeAvailable("vnc")
? "vnc"
: host.enableTelnet &&
isTabTypeAvailable("telnet")
? "telnet"
: "terminal";
onOpenTab(type, host.name);
const action = defaultConnectAction(
hostActions,
host,
);
if (action) runHostAction(action, host, shell);
})
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
@@ -599,59 +588,46 @@ export function CommandPalette({
</button>
),
)}
{host.enableSsh &&
((host.enableRdp && isTabTypeAvailable("rdp")) ||
(host.enableVnc && isTabTypeAvailable("vnc")) ||
(host.enableTelnet &&
isTabTypeAvailable("telnet"))) && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && isTabTypeAvailable("rdp") && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
onOpenTab("rdp", host.name),
);
}}
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
>
<Monitor className="size-3" />
RDP
</button>
)}
{host.enableVnc && isTabTypeAvailable("vnc") && (
<button
title="VNC"
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
onOpenTab("vnc", host.name),
);
}}
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
>
<MousePointerClick className="size-3" />
VNC
</button>
)}
{host.enableTelnet &&
isTabTypeAvailable("telnet") && (
{hostActionsFor(hostActions, host).map((action) => {
const Icon = action.icon;
const label =
action.label?.(host) ?? t(action.titleKey);
return (
<button
title="Telnet"
key={action.id}
title={label}
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
onOpenTab("telnet", host.name),
runHostAction(action, host, shell),
);
}}
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Terminal className="size-3" />
Telnet
<Icon className="size-3.5" />
</button>
)}
);
})}
{paletteEntriesFor(
paletteEntries,
"host",
host,
).map((entry) => {
const Icon = entry.icon;
return (
<button
key={entry.id}
title={t(entry.titleKey)}
onClick={(e) => {
e.stopPropagation();
handleAction(() => entry.run(shell, host));
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
{Icon && <Icon className="size-3.5" />}
</button>
);
})}
{canEditHost(host) && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
+5 -9
View File
@@ -9,8 +9,7 @@ import {
DropdownMenuSeparator,
} from "@/components/dropdown-menu";
import type { RailView } from "@/sidebar/AppRail";
import { visibleRailItems } from "@/sidebar/rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { useRailItems } from "@/sidebar/rail-items";
import type { SplitMode } from "@/types/ui-types";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
@@ -28,7 +27,7 @@ export function MobileBottomBar({
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const [hidden, setHidden] = useState<Set<string>>(readHiddenRailTabs);
const { userEnabled: aiEnabled } = useAiAvailability();
const railItems = useRailItems();
// The rail's visibility toggles apply on mobile too; this used to ignore
// them, so hiding a tab did nothing on a phone.
@@ -40,11 +39,8 @@ export function MobileBottomBar({
const { primaryItems, moreItems } = useMemo(() => {
// Tab-opening entries (network graph) have no sidebar panel to show here.
const visible = visibleRailItems().filter(
(item) =>
item.kind !== "tab" &&
!hidden.has(item.id) &&
(item.id !== "ai" || aiEnabled),
const visible = railItems.filter(
(item) => item.kind !== "tab" && !hidden.has(item.id),
);
const preferred = visible.filter((item) => item.mobilePrimary);
// Keep four primary slots filled even when the user hides the defaults,
@@ -58,7 +54,7 @@ export function MobileBottomBar({
primaryItems: primary,
moreItems: visible.filter((item) => !primaryIds.has(item.id)),
};
}, [hidden, aiEnabled]);
}, [hidden, railItems]);
const moreActive =
sidebarOpen &&
+13 -10
View File
@@ -30,17 +30,20 @@ import {
Share2,
} from "lucide-react";
import { tabIcon } from "@/shell/tabUtils";
import { isSessionTabType } from "@/shell/tab-registry";
import { isElectron } from "@/lib/electron";
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
"local-terminal",
"rdp",
"vnc",
"telnet",
];
/**
* Tabs holding a live connection that can be refreshed and shared: local
* terminals and registered session tabs. Serial sessions are not shareable.
*/
function isConnectionTab(type: TabType): boolean {
return (
type === "local-terminal" || (type !== "serial" && isSessionTabType(type))
);
}
export function TabBar({
tabs,
@@ -453,7 +456,7 @@ export function TabBar({
<div
className={`flex items-center gap-0.5 ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
>
{CONNECTION_TAB_TYPES.includes(tab.type) && (
{isConnectionTab(tab.type) && (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -466,7 +469,7 @@ export function TabBar({
<RefreshCw className="size-3" />
</button>
)}
{CONNECTION_TAB_TYPES.includes(tab.type) && onOpenShare && (
{isConnectionTab(tab.type) && onOpenShare && (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -674,7 +677,7 @@ export function TabBar({
{ctxTab.label}
</div>
<div className="h-px bg-border my-1" />
{CONNECTION_TAB_TYPES.includes(ctxTab.type) && (
{isConnectionTab(ctxTab.type) && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
+6 -12
View File
@@ -111,15 +111,11 @@ export function TabProvider({ children }: TabProviderProps) {
const computeUniqueTitle = useCallback(
(tabType: Tab["type"], desiredTitle: string | undefined): string => {
const defaultTitle =
tabType === "server_stats"
? t("nav.hostMetrics")
: tabType === "file_manager"
? t("nav.fileManager")
: tabType === "tunnel"
? t("nav.tunnels")
: tabType === "docker"
? t("nav.docker")
: t("nav.terminal");
tabType === "file_manager"
? t("nav.fileManager")
: tabType === "tunnel"
? t("nav.tunnels")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -185,10 +181,8 @@ export function TabProvider({ children }: TabProviderProps) {
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server_stats" ||
tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
tabData.type === "tunnel";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.type === "tmux_monitor" // --- tmux-monitor ---
+51 -8
View File
@@ -21,7 +21,7 @@
* subscribe/getSnapshot plumbing for useSyncExternalStore.
*/
import type { LucideIcon } from "lucide-react";
import type { ComponentType } from "react";
/**
* Args are unknown[] because the registry never inspects them: a slot owner
@@ -49,9 +49,18 @@ export interface SlotContribution {
/** Action invoked when this contribution is activated. */
actionId: string;
titleKey: string;
icon?: LucideIcon;
/** Secondary text, for slots that show one (onboarding cards). */
descriptionKey?: string;
icon?: ComponentType<{ className?: string }>;
/** Checked against the slot's accepts. Defaults to "button". */
kind?: string;
/**
* For kind "component": rendered by the slot owner with its own props,
* e.g. a panel docked into the terminal.
*/
component?: ComponentType<Record<string, unknown>>;
/** Extra condition, evaluated against the slot owner's context. */
when?: (context: Record<string, unknown>) => boolean;
pluginId?: string;
/** Lower sorts first. Ties break on actionId so ordering is stable. */
order?: number;
@@ -83,15 +92,32 @@ export function registerAction(
id: string,
handler: ActionHandler,
options: { permission?: string; pluginId?: string } = {},
): void {
actions.set(id, { id, handler, ...options });
): () => void {
const action = { id, handler, ...options };
actions.set(id, action);
emit();
return () => {
if (actions.get(id) === action) {
actions.delete(id);
emit();
}
};
}
export function unregisterAction(id: string): void {
if (actions.delete(id)) emit();
}
/** Every registered action. */
export function listActions(): RegisteredAction[] {
return [...actions.values()];
}
/** Whether a handler is registered, i.e. its plugin is running. */
export function isActionRegistered(id: string): boolean {
return actions.has(id);
}
/** The permission gating an action, if it declared one. */
export function getActionPermission(id: string): string | undefined {
return actions.get(id)?.permission;
@@ -114,9 +140,20 @@ export async function invokeAction(
return (action.handler as (...a: unknown[]) => unknown)(...args);
}
export function declareActionSlot(slot: ActionSlotDefinition): void {
export function declareActionSlot(slot: ActionSlotDefinition): () => void {
slots.set(slot.id, slot);
emit();
return () => {
if (slots.get(slot.id) === slot) {
slots.delete(slot.id);
emit();
}
};
}
/** Whether a slot is declared, i.e. its owner is running. */
export function isActionSlotDeclared(slotId: string): boolean {
return slots.has(slotId);
}
export function undeclareActionSlot(slotId: string): void {
@@ -133,7 +170,7 @@ export function undeclareActionSlot(slotId: string): void {
export function registerSlotContribution(
slotId: string,
contribution: SlotContribution,
): void {
): () => void {
const slot = slots.get(slotId);
const kind = contribution.kind ?? "button";
@@ -141,7 +178,7 @@ export function registerSlotContribution(
console.warn(
`[actions] slot "${slotId}" does not accept "${kind}" (accepts: ${slot.accepts.join(", ")})`,
);
return;
return () => {};
}
let forSlot = contributions.get(slotId);
@@ -149,8 +186,14 @@ export function registerSlotContribution(
forSlot = new Map();
contributions.set(slotId, forSlot);
}
forSlot.set(contribution.actionId, { ...contribution, kind });
const stored = { ...contribution, kind };
forSlot.set(contribution.actionId, stored);
emit();
return () => {
if (contributions.get(slotId)?.get(contribution.actionId) === stored) {
unregisterSlotContribution(slotId, contribution.actionId);
}
};
}
export function unregisterSlotContribution(
+40
View File
@@ -0,0 +1,40 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "./tab-registry";
/** A command palette entry a plugin contributes. */
export interface PaletteEntryDef {
id: string;
pluginId?: string;
titleKey: string;
icon?: ComponentType<{ className?: string }>;
keywords?: string[];
scope: "global" | "host";
when?: (host?: Host) => boolean;
run: (shell: TabShellCallbacks, host?: Host) => void;
order?: number;
}
const registry = createRegistry<PaletteEntryDef>(byOrderThenId);
export const registerPaletteEntry = registry.register;
export const usePaletteEntries = registry.useList;
export const listPaletteEntries = registry.list;
export const resetPaletteEntries = registry.reset;
export function paletteEntriesFor(
all: PaletteEntryDef[],
scope: PaletteEntryDef["scope"],
host?: Host,
): PaletteEntryDef[] {
return all.filter((entry) => {
if (entry.scope !== scope) return false;
if (!entry.when) return true;
try {
return entry.when(host);
} catch {
return false;
}
});
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import { createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "./tab-registry";
export interface PanelRenderProps {
active: boolean;
shell: TabShellCallbacks;
setEditing: (editing: boolean) => void;
activeTabType?: string;
placement: "left" | "right" | "tab";
}
/** Sidebar content for a rail view a plugin contributes. */
export interface PanelDef {
id: string;
pluginId?: string;
component: ComponentType<PanelRenderProps>;
/** Stays mounted once opened, so its state survives switching away. */
keepMounted?: boolean;
}
const registry = createRegistry<PanelDef>();
export const registerPanel = registry.register;
export const unregisterPanel = registry.unregister;
export const getPanel = registry.get;
export const listPanels = registry.list;
export const usePanels = registry.useList;
export const resetPanels = registry.reset;
-159
View File
@@ -1,159 +0,0 @@
/**
* Applies backend plugin state to the shell.
*
* Two kinds of contributed surface exist, and they are removed differently:
*
* - A tab that only exists because a plugin registered it goes away with
* unregisterTabComponent / unregisterRailItem.
* - A tab that ships as a built-in code path but is OWNED by a plugin (the
* terminal) cannot, because it was never registered. Callers ask
* isTabTypeAvailable() before offering it instead.
*
* The terminal is in the second group because its tab takes around a dozen
* props (host, split-pane state, file-manager and editor callbacks) that the
* generic { tab } plugin surface has no way to supply. Rewiring that was a
* bigger regression risk to a working terminal than gating the entry points.
*/
import { Bot } from "lucide-react";
import { unregisterRailItem } from "@/sidebar/rail-items";
import { unregisterTabComponent } from "@/shell/tabUtils";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
import {
declareActionSlot,
registerAction,
registerSlotContribution,
unregisterAction,
unregisterSlotContribution,
} from "@/shell/action-registry";
/**
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
* no rail item: it opens from a host, the command palette or quick connect.
*
* ai is a rail-view/right-dock panel rather than a tab opened via
* registerTabComponent (AiPanel is rendered directly by AppShell.tsx and
* tabUtils.tsx, the same way TerminalTabContent is for the terminal), so it
* belongs here for the same reason: a built-in surface gated by whether its
* owning plugin is installed and enabled, not something registered at
* runtime.
*/
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
"ssh-terminal": ["terminal"],
"remote-desktop": ["rdp", "vnc", "telnet"],
ai: ["ai"],
fleets: ["fleet-inventory"],
"network-topology": ["network_graph"],
"web-endpoint": ["web-endpoint"],
};
let pluginEnabled = new Map<string, boolean>();
let disabledTabTypes = new Set<string>();
const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
const AI_OPEN_WITH_CONTEXT = "ai.openWithContext";
/**
* Applies the UI action contributions the first-party plugins declare.
*
* These live in plugins/*_/frontend/index.mjs, but nothing imports those yet:
* there is no frontend plugin loader. Until there is, the registration is
* mirrored here so the contributed UI is actually reachable in the running
* app rather than only in tests. The two must be kept in step, and this block
* goes away when a real loader lands.
*/
function applyFirstPartyActions(enabled: Map<string, boolean>): void {
if (enabled.get("ssh-terminal") !== false) {
declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
}
if (enabled.get("ai") === false) {
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
unregisterAction(AI_OPEN_WITH_CONTEXT);
return;
}
registerAction(
AI_OPEN_WITH_CONTEXT,
(context: unknown) => {
window.dispatchEvent(
new CustomEvent("termix:ai:openWithContext", {
detail: { context: typeof context === "string" ? context : "" },
}),
);
},
{ permission: "ai.services.use", pluginId: "ai" },
);
registerSlotContribution(TERMINAL_TOOLBAR_SLOT, {
actionId: AI_OPEN_WITH_CONTEXT,
titleKey: "ai.assistant",
icon: Bot,
kind: "button",
pluginId: "ai",
});
}
export function applyPluginState(plugins: PluginSummary[]): void {
const nextEnabled = new Map<string, boolean>();
const nextDisabledTabs = new Set<string>();
for (const plugin of plugins) {
nextEnabled.set(plugin.id, plugin.enabled);
const builtInTabs = BUILT_IN_TABS_BY_PLUGIN[plugin.id];
if (builtInTabs) {
if (!plugin.enabled) {
for (const tabId of builtInTabs) nextDisabledTabs.add(tabId);
}
continue;
}
// Plugin-registered surfaces: only removal happens here. Registration is
// the plugin's own frontend entry being imported.
if (!plugin.enabled) {
for (const tab of plugin.contributes?.tabs ?? []) {
unregisterRailItem(tab.id);
unregisterTabComponent(tab.id);
}
}
}
pluginEnabled = nextEnabled;
disabledTabTypes = nextDisabledTabs;
applyFirstPartyActions(nextEnabled);
// The host editor's Plugins tab exists only while something fills it, so it
// is re-synced whenever the plugin list changes rather than declared once.
syncHostPluginsTab(plugins);
}
export async function refreshPluginState(): Promise<PluginSummary[]> {
const plugins = await getPlugins();
applyPluginState(plugins);
return plugins;
}
/**
* Whether a built-in tab type may still be opened. Call sites that offer a
* connection action (host item, command palette, quick connect) check this so
* a disabled plugin stops offering the tab it owns.
*/
export function isTabTypeAvailable(tabType: string): boolean {
return !disabledTabTypes.has(tabType);
}
export function isPluginEnabled(pluginId: string): boolean {
// Unknown means not yet loaded; assume enabled so the UI does not blank out
// a working feature while the first request is in flight.
return pluginEnabled.get(pluginId) ?? true;
}
/** Test seam. */
export function resetPluginState(): void {
pluginEnabled = new Map();
disabledTabTypes = new Set();
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
unregisterAction(AI_OPEN_WITH_CONTEXT);
}
+175
View File
@@ -0,0 +1,175 @@
import type {
Host,
Tab,
WorkspacePayload,
WorkspaceTabSnapshot,
} from "@/types/ui-types";
import { getTabType } from "./tab-registry";
/**
* Serializing and restoring the shell's arrangement: which tabs are open,
* how they are split, and what the docks show. This is the shell's own
* state, so it lives here; the workspaces plugin only stores and names these
* snapshots, through app.tabs.getLayout and applyLayout.
*/
/**
* Core tab types worth saving in a layout. "dashboard" is left out on
* purpose: the shell always keeps one alive as the fallback tab, so it is
* not a meaningful part of an arrangement.
*/
const CORE_CAPTURABLE = new Set([
"files",
"tunnel",
"tmux_monitor",
"serial",
"homepage",
"termix-id",
"session-logs",
"snippets",
"macros",
"history",
"ssh-tools",
]);
/** Core types reopened as singletons, with an optional preselected host. */
const CORE_SINGLETON = new Set([
"tunnel",
"tmux_monitor",
"homepage",
"termix-id",
"session-logs",
"snippets",
"macros",
"history",
"ssh-tools",
]);
export function isCapturableTabType(type: string): boolean {
if (CORE_CAPTURABLE.has(type)) return true;
const def = getTabType(type);
return !!def && def.inLayouts !== false;
}
function opensAsSingleton(type: string): boolean {
if (CORE_SINGLETON.has(type)) return true;
const def = getTabType(type);
return !!def && (!!def.singleton || !!def.hostless);
}
function createSlotId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
}
/**
* Maps live tabs to snapshots, with a fresh slotId per tab. slotId is a
* stable key within the saved payload, distinct from Tab.id, which is
* regenerated every time a tab opens.
*/
export function buildLayoutTabSnapshots(
tabs: Tab[],
genSlotId: () => string = createSlotId,
): { snapshots: WorkspaceTabSnapshot[]; slotIdByTabId: Map<string, string> } {
const capturable = tabs.filter((tab) => isCapturableTabType(tab.type));
const slotIdByTabId = new Map(capturable.map((tab) => [tab.id, genSlotId()]));
const snapshots: WorkspaceTabSnapshot[] = capturable.map((tab) => ({
slotId: slotIdByTabId.get(tab.id)!,
type: tab.type,
hostSyncId: tab.type === "serial" ? null : (tab.host?.syncId ?? null),
hostNameSnapshot: tab.host?.name ?? null,
label: tab.label,
customLabel: tab.customLabel,
initialFilePath: tab.initialFilePath,
initialPath: tab.initialPath,
data: tab.data,
serialConfig: tab.type === "serial" ? tab.serialConfig : undefined,
}));
return { snapshots, slotIdByTabId };
}
/** Remaps a slotId-keyed array (paneTabIds shape) to live tab ids. */
export function remapSlotIds(
slotIds: (string | null)[],
slotIdToTabId: Map<string, string>,
): (string | null)[] {
return slotIds.map((slotId) =>
slotId != null ? (slotIdToTabId.get(slotId) ?? null) : null,
);
}
/**
* The tab's plugin payload. Payloads saved before tabs carried `data` stored
* the fleet a fleet-inventory tab showed as a field of its own.
*/
export function snapshotData(
snapshot: WorkspaceTabSnapshot,
): Record<string, unknown> | undefined {
if (snapshot.data) return snapshot.data;
return snapshot.fleetId !== undefined
? { fleetId: snapshot.fleetId }
: undefined;
}
/**
* How to reopen one saved tab. "singleton" types go through
* openSingletonTab with an optional host; "host" types need a resolved host
* to open at all.
*/
export function resolveLayoutTabTarget(
snapshot: WorkspaceTabSnapshot,
allHosts: Host[],
):
| { kind: "serial" }
| { kind: "singleton"; host?: Host }
| { kind: "host"; host: Host }
| { kind: "skip" } {
if (snapshot.type === "serial") {
return snapshot.serialConfig ? { kind: "serial" } : { kind: "skip" };
}
let host: Host | undefined;
if (snapshot.hostSyncId) {
host = allHosts.find((h) => h.syncId === snapshot.hostSyncId);
if (!host) return { kind: "skip" };
}
if (opensAsSingleton(snapshot.type)) {
return { kind: "singleton", host };
}
return host ? { kind: "host", host } : { kind: "skip" };
}
export function buildLayoutPayload(input: {
tabs: Tab[];
activeTabId: string;
splitMode: WorkspacePayload["splitMode"];
paneTabIds: (string | null)[];
rowSizes: number[];
rowColSizes: number[][];
genSlotId?: () => string;
sidebar?: WorkspacePayload["sidebar"];
}): WorkspacePayload {
const { snapshots, slotIdByTabId } = buildLayoutTabSnapshots(
input.tabs,
input.genSlotId,
);
return {
version: 1,
tabs: snapshots,
activeSlotId: slotIdByTabId.get(input.activeTabId) ?? null,
splitMode: input.splitMode,
paneTabIds: input.paneTabIds.map((tabId) =>
tabId != null ? (slotIdByTabId.get(tabId) ?? null) : null,
),
rowSizes: input.rowSizes,
rowColSizes: input.rowColSizes,
...(input.sidebar ? { sidebar: input.sidebar } : {}),
};
}
+149
View File
@@ -0,0 +1,149 @@
import type { ComponentType, Ref } from "react";
import type { Host, Tab } from "@/types/ui-types";
import { createRegistry } from "@/lib/registry";
import { viewOwner } from "@/plugin-host/view-ownership";
/**
* The shell callbacks a registered tab may use. Same bag every tab gets, so
* a plugin tab can do anything a core tab can without new positional props.
*/
export interface TabShellCallbacks {
openTab: (
host: Host | null,
type: string,
options?: {
label?: string;
forceNewTab?: boolean;
data?: Record<string, unknown>;
},
) => void;
openSingletonTab: (
type: string,
options?: { label?: string; data?: Record<string, unknown> },
) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
openFileInEditor: (host: Host, path: string) => void;
openFileManager: (host: Host, path?: string) => void;
openTerminalTab: (host: Host, path?: string) => void;
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown, e.g. when its feature goes away. */
closeRailView: (id: string) => void;
/** Saves a quick-connect tab's host. Core terminal only. */
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
}
export interface TabRenderProps {
tab: Tab;
host?: Host;
/** The host in the shape SSH-facing components take. */
sshHost?: Record<string, unknown>;
label: string;
isVisible: boolean;
isFocusedPane: boolean;
handleRef: Ref<unknown>;
shell: TabShellCallbacks;
}
export interface StandaloneViewProps {
hostId?: string;
view: string;
params: URLSearchParams;
}
export interface TabTypeDef {
/** The tab type. */
id: string;
pluginId?: string;
component: ComponentType<TabRenderProps>;
icon?: ComponentType<{ className?: string }>;
titleKey?: string;
requiresHost?: boolean;
noHostMessageKey?: string;
persistent?: boolean;
singleton?: boolean;
session?: boolean;
hostless?: boolean;
restore?: (host: Host) => boolean;
activityTypes?: string[];
standalone?: ComponentType<StandaloneViewProps>;
standaloneViews?: string[];
panelFrame?: boolean;
/** False keeps this tab out of saved layouts and workspaces. */
inLayouts?: boolean;
preload?: () => Promise<unknown>;
}
const registry = createRegistry<TabTypeDef>();
export const registerTabType = registry.register;
export const unregisterTabType = registry.unregister;
export const getTabType = registry.get;
export const listTabTypes = registry.list;
export const subscribeToTabTypes = registry.subscribe;
export const useTabTypes = registry.useList;
export const resetTabTypes = registry.reset;
/** Core tab types that are saved and reopened after login. */
const CORE_PERSISTENT = new Set(["files", "tunnel"]);
/** Core tab types that hold a live session (close confirm, refresh). */
const CORE_SESSION = new Set(["serial"]);
/** Core tab types that restore without a host. */
const CORE_HOSTLESS = new Set(["dashboard", "tunnel"]);
/**
* A plugin tab type nobody registered right now: its plugin is off, failed or
* still loading. It keeps the behaviour it had, so a saved tab survives the
* plugin being disabled instead of being dropped by the next sync.
*/
function isOwnedButAbsent(type: string): boolean {
return !registry.get(type) && !!viewOwner("tab", type);
}
export function isPersistentTabType(type: string): boolean {
if (CORE_PERSISTENT.has(type)) return true;
const def = registry.get(type);
if (def) return !!def.persistent;
return isOwnedButAbsent(type);
}
export function isSessionTabType(type: string): boolean {
return CORE_SESSION.has(type) || !!registry.get(type)?.session;
}
export function isHostlessTabType(type: string): boolean {
return CORE_HOSTLESS.has(type) || !!registry.get(type)?.hostless;
}
/** Whether a saved tab of this type may be restored for this host. */
export function canRestoreTabType(
type: string,
host: Host | undefined,
): boolean {
const def = registry.get(type);
if (!host) return isHostlessTabType(type) || isOwnedButAbsent(type);
return def?.restore ? def.restore(host) : true;
}
/** The tab type a recent-activity entry opens, if a registered tab claims it. */
export function tabTypeForActivity(
activityType: string,
): TabTypeDef | undefined {
return registry
.list()
.find(
(def) =>
def.id === activityType || def.activityTypes?.includes(activityType),
);
}
/** The registered tab serving a `?view=` full-screen link. */
export function standaloneViewFor(view: string): TabTypeDef | undefined {
return registry
.list()
.find(
(def) =>
def.standalone &&
(def.id === view || def.standaloneViews?.includes(view)),
);
}
+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}
/>
);
}
}
}