feat: rebuild workspaces as the reference plugin

This commit is contained in:
LukeGus committed 2026-09-23 07:04:03 -05:00
1 parent d774df8b41
commit f217d8db89
79 files changed
+32995 -2184

No files matched your search

+19
View File
@@ -43,6 +43,8 @@ import { shell, tabsApi } from "./shell-bridge";
import { withPluginScope } from "./scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
export interface PluginAppHandle {
app: TermixApp;
@@ -118,6 +120,9 @@ export function createPluginApp(
after: item.after,
order: item.order,
pluginId,
permission: item.permission
? resolvePluginPermission(pluginId, item.permission)
: undefined,
}),
);
},
@@ -339,6 +344,20 @@ export function createPluginApp(
);
},
t: ((key: string, options?: Record<string, unknown> | string) =>
i18n.t(
pluginKey(pluginId, key),
options as Record<string, unknown>,
)) as TermixApp["t"],
hasPermission: async (permission) => {
try {
return await hasPermission(
resolvePluginPermission(pluginId, permission),
);
} catch {
return false;
}
},
api: pluginHostBridge.getApi(pluginId),
wsUrl: (path, options) =>
pluginWsUrl(pluginId, path, options as never) as ReturnType<
+9
View File
@@ -56,6 +56,15 @@ export function resolvePluginPermission(
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
/** Test seam: renderWithApp's `api` option stands in for the real client. */
export function setPluginApiForTesting(
pluginId: string,
client: ReturnType<typeof createPluginApi> | null,
): void {
if (client) apiClients.set(pluginId, client);
else apiClients.delete(pluginId);
}
function getApi(pluginId: string) {
let client = apiClients.get(pluginId);
if (!client) {
+3
View File
@@ -58,6 +58,9 @@ export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
// Design tokens: the colour swatches folders, workspaces and tags pick from.
export { FOLDER_COLORS } from "@/lib/theme";
// Shell context
export { useTabs, useTabsSafe } from "@/shell/TabContext";
+75 -4
View File
@@ -44,9 +44,18 @@ import { getSlotContributions, listActions } from "@/shell/action-registry";
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge } from "./bridge";
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
import { setPluginSummaries, setFrontendState } from "./plugin-store";
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
import {
notifyShellReady,
resetShellBridge,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
} from "./shell-bridge";
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
const record =
@@ -78,7 +87,7 @@ function wrap(element: ReactElement): HTMLElement {
).container;
}
function missing(kind: string, id: string): never {
function missing(kind: string, id: string | number): never {
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
}
@@ -101,12 +110,45 @@ export async function renderPlugin(
} as PluginManifest;
installPluginHostBridge();
resetShellBridge();
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
setShellHosts((options.hosts ?? []) as unknown as Host[]);
const hosts = (options.hosts ?? []) as unknown as Host[];
setShellHosts(hosts);
const shellCalls: ShellCall[] = [];
const shell = recordingShell(shellCalls);
setShellCallbacks(shell);
// Stands in for AppShell's layout: applying one runs the shell's own
// restore rules and records which tabs it would open.
let layout = options.layout ?? null;
const opened: Array<{ type: string; hostId?: number; label: string }> = [];
setShellLayoutProvider({
getLayout: () => layout,
applyLayout: async (next, applyOptions) => {
shellCalls.push({ method: "applyLayout", args: [next, applyOptions] });
layout = next;
opened.length = 0;
const skipped: string[] = [];
const tabs = (next.tabs ?? []) as WorkspaceTabSnapshot[];
for (const snapshot of tabs) {
const target = resolveLayoutTabTarget(snapshot, hosts);
if (target.kind === "skip") {
skipped.push(snapshot.hostNameSnapshot || snapshot.label);
continue;
}
opened.push({
type: snapshot.type,
hostId:
"host" in target && target.host
? Number(target.host.id)
: undefined,
label: snapshot.customLabel ?? snapshot.label,
});
}
return { skipped };
},
});
const summary: PluginSummary = {
id: pluginId,
name: manifest.name,
@@ -128,11 +170,16 @@ export async function renderPlugin(
);
}
setPluginApiForTesting(
pluginId,
(options.api as Parameters<typeof setPluginApiForTesting>[1]) ?? null,
);
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
await plugin.activate(handle.app);
setFrontendState(pluginId, "active");
if (options.ready) notifyShellReady();
const mine = <T extends { pluginId?: string }>(items: T[]) =>
items.filter((item) => item.pluginId === pluginId);
@@ -154,6 +201,7 @@ export async function renderPlugin(
mine(listRegisteredRailItems()).map((item) => ({
id: item.id,
hidden: item.hidden,
permission: item.permission,
})),
tabs: () => mine(listTabTypes()).map((def) => def.id),
panels: () => mine(listPanels()).map((panel) => panel.id),
@@ -195,6 +243,28 @@ export async function renderPlugin(
);
},
openedTabs: () => opened.map((tab) => ({ ...tab })),
renderOpenedTab(index) {
const tab = opened[index] ?? missing("an opened tab at index", index);
const def = getTabType(tab.type);
if (!def) {
// What tabUtils renders for a tab no running plugin registered.
return wrap(<PluginViewPlaceholder kind="tab" viewId={tab.type} />);
}
const Component = def.component;
return wrap(
<Component
tab={tabRecord(tab.type)}
label={tab.label}
isVisible
isFocusedPane
handleRef={null}
shell={shell}
/>,
);
},
renderPanel(id, props = {}) {
const panel = getPanel(id) ?? missing("a panel", id);
const Component = panel.component;
@@ -260,6 +330,7 @@ export async function renderPlugin(
} finally {
handle.dispose();
setFrontendState(pluginId, "inactive");
setPluginApiForTesting(pluginId, null);
}
},
};