diff --git a/package-lock.json b/package-lock.json index 78e0ee0a2..0e5d4e310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.11.0", + "version": "26.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.11.0", + "version": "26.11.1", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index e313cca48..9e0231904 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.11.0", + "version": "26.11.1", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index e37af6b2c..d4f14ce81 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -10,6 +10,8 @@ Plugin API 1.14.0. - `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities - `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions - `contributes.hostSections` declares cards for the host page +- `app.registerHostSection` adds a card to the host page +- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes ## 1.0.22 diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index a263b1974..b604057fe 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution; /** A control in the host editor's toolbar. */ export type HostEditorToolbarItemContribution = HostBadgeContribution; +export interface HostSectionProps { + host: PluginHostRecord; + isVisible: boolean; + shell: ShellApi; +} + +export interface HostSectionContribution { + /** Declare it in manifest contributes.hostSections too. */ + id: string; + titleKey: string; + /** Lower sorts first. */ + order?: number; + when?: (host: PluginHostRecord) => boolean; + component: ComponentType; +} + export interface HostContextMenuItemContribution { id: string; titleKey: string; @@ -1172,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo { registerHostContextMenuItem: ( item: HostContextMenuItemContribution, ) => Disposer; + /** + * A card on the host page. Core 26.11.1 and newer; call it as + * app.registerHostSection?.(...) if your plugin allows older cores. + */ + registerHostSection: (section: HostSectionContribution) => Disposer; registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer; registerPaletteGroup: (group: PaletteGroupContribution) => Disposer; registerKeybindingAction: (action: KeybindingActionContribution) => Disposer; diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index ef0635403..344c67b5c 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput HostProtocolDef (type): HostProtocolDef HostProtocols (type): HostProtocols HostRowFields (type): HostRowFields +HostSectionDef (type): HostSectionDef HostSidebarFilterState (type): HostSidebarFilterState HostSidebarPreferences (type): HostSidebarPreferences +HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record; isVisible?: boolean; shell: TabShellCallbacks; }) => Element HostTrayTrigger (type): HostTrayTrigger HttpsSettings (type): HttpsSettings IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element @@ -457,6 +459,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record>; }, list?: HostProtocolDef[]) => HostProtocols +hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[] hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hydrateLocalSharedHostAuth (value): (host: T) => Promise invalidateHostsAndStatusCaches (value): () => void @@ -475,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: linkedServerUrl (value): () => Promise listHostActions (value): () => HostActionDef[] listHostProtocols (value): () => HostProtocolDef[] +listHostSections (value): () => HostSectionDef[] loadPaletteGroup (value): (group: PaletteGroupDef) => Promise logoutUser (value): () => Promise<{ success: boolean; message: string; }> makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record, defaultsMode?: boolean) => HostTab[] @@ -488,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void normalizeLanguageCode (value): (language?: string) => string notifySyncChanged (value): () => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void +operationsApi (value): AxiosInstance paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseServerTime (value): (ts: string) => Date @@ -598,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[] useHostEditorSections (value): () => HostEditorSectionDef[] useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostProtocols (value): () => HostProtocolDef[] +useHostSections (value): () => HostSectionDef[] useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatusIndicators (value): () => HostBadgeDef[] useInstalledPlugins (value): () => PluginSummary[] diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index bd6b4b115..170c112ce 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "useHostSections", + "operationsApi", + "listHostSections", + "hostSectionsFor", + "HostTabEmbed", + "HostSectionDef", "ACCENT_PRESET_COLORS", "APP_VERSION", "AccessRecord", diff --git a/src/ui/lib/plugin-transport.ts b/src/ui/lib/plugin-transport.ts index 73fabd822..7da0df865 100644 --- a/src/ui/lib/plugin-transport.ts +++ b/src/ui/lib/plugin-transport.ts @@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance { }) as AxiosInstance; } +/** + * An axios client rooted at core's /operations routes: the action catalog, + * runs, approvals, events, signals and cross host resources. + */ +export function createOperationsApi(): AxiosInstance { + return new Proxy(authApi, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + if ( + property === "get" || + property === "delete" || + property === "post" || + property === "put" || + property === "patch" + ) { + return (url: string, ...rest: unknown[]) => + (value as (...args: unknown[]) => unknown).call( + target, + `/operations${url.startsWith("/") ? url : `/${url}`}`, + ...rest, + ); + } + return value.bind(target); + }, + }) as AxiosInstance; +} + /** * A raw fetch on this plugin's /plugin-api mount point, for a response the * caller reads as a stream (server-sent events). axios buffers the whole diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 3ce15fdfc..f84c8fe70 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -41,6 +41,10 @@ import { type KeybindingEditorProps, } from "@/shell/keybinding-registry"; import { registerHostProtocol } from "@/sidebar/host-protocols"; +import { + registerHostSection, + type HostSectionRenderProps, +} from "@/shell/host-sections"; import { registerDashboardCard, type DashboardCardRenderProps, @@ -424,6 +428,21 @@ export function createPluginApp( ); }, + registerHostSection(section) { + return track( + registerHostSection({ + id: section.id, + pluginId, + titleKey: key(section.titleKey), + order: section.order, + when: hostCallback(section.when ?? (() => true)) as never, + component: scoped( + section.component as unknown as ComponentType, + ), + }), + ); + }, + registerHostContextMenuItem(item) { return track( registerHostContextMenuItem({ diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 28271a659..5f8d6f7ed 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -823,3 +823,13 @@ export { rightDockableIds, useRailItems, } from "@/sidebar/rail-items"; +export { + hostSectionsFor, + type HostSectionDef, + listHostSections, + useHostSections, +} from "@/shell/host-sections"; +export { HostTabEmbed } from "@/shell/HostTabEmbed"; +import { createOperationsApi } from "@/lib/plugin-transport"; +/** Core's /operations routes, with the user's own session. */ +export const operationsApi = createOperationsApi(); diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index 14793cb8e..c2c176215 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -471,6 +471,7 @@ export { resetHostContributions, } from "@/sidebar/host-contributions"; export { registerHostProtocol } from "@/sidebar/host-protocols"; +export { registerHostSection, resetHostSections } from "@/shell/host-sections"; export { RAIL_ITEMS, registerRailItem, diff --git a/src/ui/shell/HostTabEmbed.tsx b/src/ui/shell/HostTabEmbed.tsx new file mode 100644 index 000000000..b722ee8c8 --- /dev/null +++ b/src/ui/shell/HostTabEmbed.tsx @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import type { Host, Tab } from "@/types/ui-types"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; +import { renderTabContent } from "@/shell/tabUtils"; + +/** + * Draws a registered tab type inside another view, such as a host page, with + * the same props the tab stage gives it. The embed has its own instance id, so + * it never collides with the same tool opened as a normal tab. + */ +export function HostTabEmbed({ + host, + type, + label, + data, + isVisible = true, + shell, +}: { + host: Host; + type: string; + label?: string; + data?: Record; + isVisible?: boolean; + shell: TabShellCallbacks; +}) { + const tab = useMemo( + () => ({ + id: `embed-${type}-${host.id}`, + instanceId: `embed-${type}-${host.id}`, + type, + label: label ?? host.name ?? host.ip, + host, + openedAt: Date.now(), + data, + }), + // A new host or tool is a new tab; label and data changes keep it. + // eslint-disable-next-line react-hooks/exhaustive-deps + [type, host.id], + ); + return ( +
+ {renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })} +
+ ); +} diff --git a/src/ui/shell/host-sections.ts b/src/ui/shell/host-sections.ts new file mode 100644 index 000000000..5c511cde4 --- /dev/null +++ b/src/ui/shell/host-sections.ts @@ -0,0 +1,43 @@ +import type { ComponentType } from "react"; +import type { Host } from "@/types/ui-types"; +import { byOrderThenId, createRegistry } from "@/lib/registry"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; + +export interface HostSectionRenderProps { + host: Host; + isVisible: boolean; + shell: TabShellCallbacks; +} + +/** + * A card a plugin adds to a host page. The page itself belongs to a plugin; + * core only keeps the list so any plugin can fill it. + */ +export interface HostSectionDef { + id: string; + pluginId?: string; + titleKey: string; + order?: number; + when: (host: Host) => boolean; + component: ComponentType; +} + +const sections = createRegistry(byOrderThenId); + +export const registerHostSection = sections.register; +export const useHostSections = sections.useList; +export const listHostSections = sections.list; +export const resetHostSections = sections.reset; + +export function hostSectionsFor( + all: HostSectionDef[], + host: Host, +): HostSectionDef[] { + return all.filter((section) => { + try { + return section.when(host); + } catch { + return false; + } + }); +}