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,6 +1,6 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
LayoutTemplate,
-34
View File
@@ -1,34 +0,0 @@
/**
* Frontend half of the workspaces plugin.
*
* WorkspacesPanel is a left-sidebar railView panel (opened from the rail,
* not a Tab), the same shape as FleetsPanel/HostsPanel/CredentialsPanel.
* None of those go through registerRailItem/registerTabComponent either --
* per src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like
* this is rendered directly by AppShell.tsx rather than registered at
* runtime. WorkspacesPanel is reached today through a direct relative
* import in AppShell.tsx (../../plugins/workspaces/frontend/WorkspacesPanel),
* not through register() below.
*
* register()/unregister() still follow the same shape every other plugin's
* frontend entry does, so this is ready the moment a real frontend plugin
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet".
*/
export const id = "workspaces";
export const railViewId = "workspaces";
export async function register({ registerRailItem, icons }) {
registerRailItem?.({
id: railViewId,
icon: icons.LayoutTemplate,
labelKey: "nav.workspaces",
separatorAfter: true,
});
}
export async function unregister({ unregisterRailItem }) {
unregisterRailItem?.(railViewId);
}
+95
View File
@@ -0,0 +1,95 @@
import { LayoutTemplate } from "lucide-react";
import i18next from "i18next";
import { toast } from "sonner";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
import { WorkspacesPanel } from "./WorkspacesPanel";
import {
applyWorkspaceServer,
listWorkspaces,
saveLastSessionWorkspace,
} from "./workspaces-api";
const LAST_SESSION_DELAY_MS = 2000;
export function activate(app: TermixApp): void {
const applyWorkspace = async (workspace: Workspace) => {
const { skipped } = await app.tabs.applyLayout(
workspace.payload as unknown as Parameters<
typeof app.tabs.applyLayout
>[0],
{ name: workspace.name },
);
if (skipped.length > 0) {
toast.warning(
i18next.t("workspaces:newUi.sidebar.workspaces.tabsSkipped", {
count: skipped.length,
names: skipped.join(", "),
}),
);
}
applyWorkspaceServer(workspace.id).catch(() => {});
};
function Panel({ active }: PanelProps) {
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<WorkspacesPanel
active={active}
currentPayload={() =>
app.tabs.getLayout() as unknown as WorkspacePayload
}
onApplyWorkspace={(workspace) => void applyWorkspace(workspace)}
/>
</div>
);
}
app.registerRailItem({
id: "workspaces",
icon: LayoutTemplate,
titleKey: "nav.workspaces",
after: "split-screen",
});
app.registerPanel("workspaces", Panel);
// After login, a default workspace applies unless the session already
// brought tabs back, which is the more precise restore.
app.tabs.onReady(() => {
const layout = app.tabs.getLayout() as unknown as WorkspacePayload | null;
if (layout && layout.tabs.length > 0) return;
listWorkspaces()
.then((workspaces) => {
const preferred = workspaces.find(
(workspace) => workspace.kind === "manual" && workspace.isDefault,
);
if (preferred) void applyWorkspace(preferred);
})
.catch(() => {});
});
// Keeps an implicit "last session" snapshot current, so an arrangement can
// always be recovered even if it was never saved.
let timer: ReturnType<typeof setTimeout> | null = null;
app.tabs.onChange(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const layout = app.tabs.getLayout();
if (layout) {
saveLastSessionWorkspace(layout as unknown as WorkspacePayload).catch(
() => {},
);
}
}, LAST_SESSION_DELAY_MS);
});
app.onDispose(() => {
if (timer) clearTimeout(timer);
});
app.registerSlotContribution("onboarding.workflow", {
actionId: "workspaces.tip",
titleKey: "onboarding.workflow_workspaces",
descriptionKey: "onboarding.workflow_workspaces_desc",
icon: LayoutTemplate,
});
}
@@ -1,163 +0,0 @@
import type {
Host,
Tab,
TabType,
WorkspacePayload,
WorkspaceTabSnapshot,
} from "@/types/ui-types";
// "dashboard" is deliberately excluded: AppShell always keeps a dashboard
// tab alive as the fallback when every other tab closes, so it isn't a
// meaningful part of a saved arrangement and shouldn't count toward
// tabCount or be reopened on apply.
export const WORKSPACE_CAPTURABLE_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"host-metrics",
"proxmox-stats",
"files",
"docker",
"tunnel",
"network_graph",
"tmux_monitor",
"serial",
"homepage",
"fleet-inventory",
"termix-id",
"session-logs",
"snippets",
"macros",
"history",
"ssh-tools",
"automations",
"ai",
];
export const HOSTLESS_WORKSPACE_TAB_TYPES: TabType[] = [
"tunnel",
"network_graph",
"tmux_monitor",
"homepage",
"fleet-inventory",
"docker",
"termix-id",
"session-logs",
"snippets",
"macros",
"history",
"ssh-tools",
"automations",
"ai",
];
function createWorkspaceSlotId(): 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 WorkspaceTabSnapshots, generating 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 is opened).
*/
export function buildWorkspaceTabSnapshots(
tabs: Tab[],
genSlotId: () => string = createWorkspaceSlotId,
): { snapshots: WorkspaceTabSnapshot[]; slotIdByTabId: Map<string, string> } {
const capturable = tabs.filter((t) =>
WORKSPACE_CAPTURABLE_TYPES.includes(t.type),
);
const slotIdByTabId = new Map(capturable.map((t) => [t.id, genSlotId()]));
const snapshots: WorkspaceTabSnapshot[] = capturable.map((t) => ({
slotId: slotIdByTabId.get(t.id)!,
type: t.type,
hostSyncId: t.type === "serial" ? null : (t.host?.syncId ?? null),
hostNameSnapshot: t.host?.name ?? null,
label: t.label,
customLabel: t.customLabel,
initialFilePath: t.initialFilePath,
initialPath: t.initialPath,
fleetId: t.fleetId,
serialConfig: t.type === "serial" ? t.serialConfig : undefined,
}));
return { snapshots, slotIdByTabId };
}
/** Remaps a slotId-keyed array (paneTabIds shape) to live tab ids via a slotId->tabId map. */
export function remapSlotIds(
slotIds: (string | null)[],
slotIdToTabId: Map<string, string>,
): (string | null)[] {
return slotIds.map((slotId) =>
slotId != null ? (slotIdToTabId.get(slotId) ?? null) : null,
);
}
/**
* Resolves how to (re)open a single workspace-payload tab on apply.
*
* "singleton" covers every rail-singleton tab type (dashboard/tunnel/docker/
* etc.) whether or not a host was resolved for it - these always go through
* openSingletonTab, which accepts an optional preselected host. "host" is
* for genuinely per-host tab types (terminal/rdp/files/...), which always
* need a resolved host to open at all.
*/
export function resolveWorkspaceTabTarget(
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 (HOSTLESS_WORKSPACE_TAB_TYPES.includes(snapshot.type)) {
return { kind: "singleton", host };
}
return host ? { kind: "host", host } : { kind: "skip" };
}
export function buildWorkspacePayload(input: {
tabs: Tab[];
activeTabId: string;
splitMode: WorkspacePayload["splitMode"];
paneTabIds: (string | null)[];
rowSizes: number[];
rowColSizes: number[][];
genSlotId?: () => string;
sidebar?: WorkspacePayload["sidebar"];
}): WorkspacePayload {
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
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 } : {}),
};
}