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

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { PERSISTENT_TAB_TYPES } from "@/AppShell";
describe("PERSISTENT_TAB_TYPES", () => {
it("excludes web-endpoint", () => {
// Not merely scope. The backend closes an idle tunnel after ten minutes
// and re-binds a fresh kernel-assigned port on the next open, so a
// restored web-endpoint tab could never hold a valid URL -- and the
// endpoint may have been edited or deleted meanwhile besides. Restoring
// one would mean re-opening the tunnel on restore, which is a feature.
//
// This test exists so nobody adds it later for symmetry and ships tabs
// that restore broken.
expect(PERSISTENT_TAB_TYPES).not.toContain("web-endpoint");
});
it("still persists the tab types that can be restored", () => {
// Guards the guard: an empty or gutted list would pass the assertion above
// vacuously.
expect(PERSISTENT_TAB_TYPES).toContain("terminal");
expect(PERSISTENT_TAB_TYPES).toContain("tunnel");
expect(PERSISTENT_TAB_TYPES.length).toBeGreaterThanOrEqual(8);
});
});
-256
View File
@@ -1,256 +0,0 @@
/**
* Disabling a plugin has to remove the surfaces it owns, and disabling one
* plugin must not touch another's.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { Puzzle } from "lucide-react";
import {
applyPluginState,
isPluginEnabled,
isTabTypeAvailable,
resetPluginState,
} from "@/shell/pluginLoader";
import {
registerRailItem,
unregisterRailItem,
visibleRailItems,
} from "@/sidebar/rail-items";
import type { PluginSummary } from "@/api/plugins-api";
vi.mock("@/api/plugins-api", () => ({
getPlugins: vi.fn().mockResolvedValue([]),
setPluginEnabled: vi.fn(),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "ssh-terminal",
name: "SSH Terminal",
version: "1.0.0",
tier: "bundled",
source: "bundled",
enabled: true,
state: "active",
lastError: null,
contributes: {
tabs: [
{
id: "terminal",
titleKey: "nav.terminal",
icon: "SquareTerminal",
openFrom: ["host-context-menu", "palette"],
},
],
},
capabilities: [],
grantedCapabilities: [],
...overrides,
};
}
function aiPlugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return plugin({
id: "ai",
name: "AI Assistant",
contributes: {
tabs: [
{
id: "ai",
titleKey: "nav.ai",
icon: "Sparkles",
openFrom: ["rail", "palette"],
},
],
},
...overrides,
});
}
function remoteDesktopPlugin(
overrides: Partial<PluginSummary> = {},
): PluginSummary {
return plugin({
id: "remote-desktop",
name: "Remote Desktop",
contributes: {
tabs: [
{
id: "rdp",
titleKey: "hosts.tabRdp",
icon: "Monitor",
openFrom: ["host-context-menu", "palette"],
},
{
id: "vnc",
titleKey: "hosts.tabVnc",
icon: "MousePointerClick",
openFrom: ["host-context-menu", "palette"],
},
{
id: "telnet",
titleKey: "hosts.tabTelnet",
icon: "Terminal",
openFrom: ["host-context-menu", "palette"],
},
],
},
...overrides,
});
}
describe("plugin state applied to the shell", () => {
afterEach(() => {
resetPluginState();
unregisterRailItem("other-plugin-tab");
});
it("allows terminal tabs while the plugin is enabled", () => {
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("blocks terminal tabs when the plugin is disabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
});
it("allows them again when the plugin is re-enabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("leaves unrelated tab types alone", () => {
applyPluginState([plugin({ enabled: false })]);
// Disabling the terminal must not take the rest of the app with it.
for (const type of ["files", "docker", "tunnel", "rdp", "host-metrics"]) {
expect(isTabTypeAvailable(type)).toBe(true);
}
});
it("leaves another plugin's rail item alone when the terminal is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
applyPluginState([
plugin({ enabled: false }),
plugin({
id: "other-plugin",
name: "Other",
tier: "community",
source: "registry",
enabled: true,
contributes: null,
}),
]);
expect(isTabTypeAvailable("terminal")).toBe(false);
// The core ask: disabling one plugin must not disturb another.
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
});
it("unregisters a plugin-registered tab when that plugin is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
applyPluginState([
plugin({
id: "other-plugin",
name: "Other",
enabled: false,
contributes: {
tabs: [
{
id: "other-plugin-tab",
titleKey: "nav.otherPlugin",
icon: "Puzzle",
openFrom: ["rail"],
},
],
},
}),
]);
expect(visibleRailItems().map((i) => i.id)).not.toContain(
"other-plugin-tab",
);
});
it("allows the ai tab while the plugin is enabled", () => {
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("blocks the ai tab when the plugin is disabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
});
it("allows the ai tab again when the plugin is re-enabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("disabling ai does not affect the terminal, and vice versa", () => {
applyPluginState([aiPlugin({ enabled: false }), plugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(false);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("allows rdp/vnc/telnet tabs while remote-desktop is enabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
expect(isTabTypeAvailable("rdp")).toBe(true);
expect(isTabTypeAvailable("vnc")).toBe(true);
expect(isTabTypeAvailable("telnet")).toBe(true);
});
it("blocks rdp/vnc/telnet tabs when remote-desktop is disabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
expect(isTabTypeAvailable("rdp")).toBe(false);
expect(isTabTypeAvailable("vnc")).toBe(false);
expect(isTabTypeAvailable("telnet")).toBe(false);
});
it("allows rdp/vnc/telnet tabs again when remote-desktop is re-enabled", () => {
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
expect(isTabTypeAvailable("rdp")).toBe(false);
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
expect(isTabTypeAvailable("rdp")).toBe(true);
expect(isTabTypeAvailable("vnc")).toBe(true);
expect(isTabTypeAvailable("telnet")).toBe(true);
});
it("disabling remote-desktop does not affect the terminal, and vice versa", () => {
applyPluginState([
remoteDesktopPlugin({ enabled: false }),
plugin({ enabled: true }),
]);
expect(isTabTypeAvailable("rdp")).toBe(false);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("reports enablement, defaulting to true before the first load", () => {
expect(isPluginEnabled("ssh-terminal")).toBe(true);
applyPluginState([plugin({ enabled: false })]);
expect(isPluginEnabled("ssh-terminal")).toBe(false);
expect(isPluginEnabled("never-heard-of-it")).toBe(true);
});
});
+382
View File
@@ -0,0 +1,382 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildLayoutPayload as buildWorkspacePayload,
buildLayoutTabSnapshots as buildWorkspaceTabSnapshots,
remapSlotIds,
resolveLayoutTabTarget as resolveWorkspaceTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
// Plugin tab types take part in layouts only while registered, so the ones
// these cases use are registered the way their plugins would.
beforeEach(() => {
registerTabType({ id: "terminal", component: () => null, persistent: true });
registerTabType({
id: "fleet-inventory",
component: () => null,
singleton: true,
hostless: true,
});
registerTabType({
id: "web-endpoint",
component: () => null,
inLayouts: false,
});
});
afterEach(() => {
vi.unstubAllGlobals();
resetTabTypes();
});
function makeHost(overrides: Partial<Host> = {}): Host {
return {
id: "1",
name: "web-01",
username: "root",
ip: "10.0.0.1",
port: 22,
folder: "",
online: true,
cpu: null,
ram: null,
lastAccess: "2026-01-01T00:00:00.000Z",
authType: "password",
enableTerminal: true,
enableCommandHistory: true,
syncId: "sync-web-01",
...overrides,
} as Host;
}
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: "tab-1",
instanceId: "instance-1",
type: "terminal",
label: "web-01",
openedAt: 0,
...overrides,
} as Tab;
}
describe("buildWorkspaceTabSnapshots", () => {
it("falls back when randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2" })];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(2);
expect(snapshots[0].slotId).toBeTruthy();
expect(snapshots[1].slotId).not.toBe(snapshots[0].slotId);
});
it("captures host-bound and singleton tabs with generated slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host, label: "web-01" }),
makeTab({ id: "t2", type: "tunnel", label: "Tunnels" }),
];
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
tabs,
genSlotId,
);
expect(snapshots).toHaveLength(2);
expect(snapshots[0]).toMatchObject({
slotId: "slot-0",
type: "terminal",
hostSyncId: "sync-web-01",
hostNameSnapshot: "web-01",
});
expect(snapshots[1]).toMatchObject({
slotId: "slot-1",
type: "tunnel",
hostSyncId: null,
});
expect(slotIdByTabId.get("t1")).toBe("slot-0");
expect(slotIdByTabId.get("t2")).toBe("slot-1");
});
it("excludes rail-view pseudo tabs (host-manager, user-profile, admin-settings)", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "host-manager" }),
makeTab({ id: "t2", type: "user-profile" }),
makeTab({ id: "t3", type: "admin-settings" }),
makeTab({ id: "t4", type: "tunnel" }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("tunnel");
});
it("excludes the dashboard tab - it's a permanent fallback, not a meaningful part of a saved arrangement", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "dashboard" }),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("terminal");
});
it("captures serialConfig only for serial tabs", () => {
const tabs: Tab[] = [
makeTab({
id: "t1",
type: "serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
}),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots[0].serialConfig).toEqual({
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
});
expect(snapshots[0].hostSyncId).toBeNull();
expect(snapshots[1].serialConfig).toBeUndefined();
});
});
describe("remapSlotIds", () => {
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
const map = new Map([
["slot-a", "new-tab-1"],
["slot-b", "new-tab-2"],
]);
const result = remapSlotIds(
["slot-a", null, "slot-b", "slot-missing"],
map,
);
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
});
});
describe("resolveWorkspaceTabTarget", () => {
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
it("resolves a serial snapshot with a config as 'serial'", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "serial",
});
});
it("skips a serial snapshot missing its config", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a host-bound snapshot by matching syncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "web-01",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("host");
expect((result as { host: Host }).host.syncId).toBe("sync-web-01");
});
it("skips a host-bound snapshot whose host no longer exists", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "deleted-host",
hostSyncId: "sync-gone",
hostNameSnapshot: "deleted-host",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a hostless singleton type without a hostSyncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "tunnel",
label: "Tunnels",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "singleton",
host: undefined,
});
});
it("resolves a singleton type WITH a resolved host as singleton+host (e.g. fleet-inventory)", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "fleet-inventory",
label: "Fleet",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("singleton");
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
});
it("skips a non-singleton, non-serial type with no resolvable host", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "files",
label: "files",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
});
describe("buildWorkspacePayload", () => {
it("builds a full payload including pane assignment remapped to slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host }),
makeTab({ id: "t2", type: "files", host }),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "t1",
splitMode: "2-way",
paneTabIds: ["t1", "t2", null, null, null, null],
rowSizes: [100],
rowColSizes: [[50, 50]],
genSlotId,
});
expect(payload.version).toBe(1);
expect(payload.activeSlotId).toBe("slot-0");
expect(payload.paneTabIds).toEqual([
"slot-0",
"slot-1",
null,
null,
null,
null,
]);
expect(payload.splitMode).toBe("2-way");
expect(payload.rowSizes).toEqual([100]);
expect(payload.rowColSizes).toEqual([[50, 50]]);
});
it("sets activeSlotId to null when the active tab is not capturable", () => {
const payload = buildWorkspacePayload({
tabs: [makeTab({ id: "t1", type: "host-manager" })],
activeTabId: "t1",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.activeSlotId).toBeNull();
expect(payload.tabs).toHaveLength(0);
});
it("round-trips the sidebar arrangement", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
sidebar: {
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
},
});
expect(payload.sidebar).toEqual({
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
});
});
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.sidebar).toBeUndefined();
expect(payload.version).toBe(1);
});
});
describe("plugin tab types in layouts", () => {
it("leaves out a tab type that opts out of layouts", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "w", type: "web-endpoint" }),
makeTab({ id: "t", type: "terminal" }),
]);
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
});
it("leaves out a plugin tab type nobody registered", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "x", type: "some-plugin-tab" }),
]);
expect(snapshots).toEqual([]);
});
it("carries a tab's plugin data and reads the older fleetId field", () => {
const { snapshots } = buildWorkspaceTabSnapshots([
makeTab({ id: "f", type: "fleet-inventory", data: { fleetId: 3 } }),
]);
expect(snapshotData(snapshots[0])).toEqual({ fleetId: 3 });
expect(
snapshotData({
slotId: "s",
type: "fleet-inventory",
label: "",
fleetId: 7,
}),
).toEqual({ fleetId: 7 });
});
});