mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-11 06:11:31 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user