diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index dc7d125d7..8a964291e 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -6,6 +6,7 @@ Plugin API 1.10.0. ### Added +- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order - `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS ## 1.0.17 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index dacad73d8..422fd1825 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -496,6 +496,7 @@ your plugin's error boundary and Suspense. | `registerRailItem` | A sidebar rail button | | `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) | | `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle | +| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type | | `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) | | `registerHostAction` | A host button, palette entry or default connect | | `registerHostEditorSection` | Your own host editor section (host settings get one for free) | diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index efe58c567..845b44200 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -825,6 +825,26 @@ export interface CompanionDefaults { edgeButton?: boolean; } +/** A panel a tab type can show beside it, for a picker. */ +export interface CompanionInfo { + /** The id registerCompanionLayout's hidden and order lists use. */ + id: string; + titleKey: string; + icon?: IconComponent; +} + +/** How a host wants the panel bar beside a tab. */ +export interface CompanionLayout { + /** False removes the bar and its panels. */ + enabled?: boolean; + /** Where a panel opens until the user moves it. */ + side?: "left" | "right" | "bottom"; + /** Companion ids in bar order. */ + order?: string[]; + /** Companion ids left out. */ + hidden?: string[]; +} + export interface CompanionProps { /** The tab it sits beside. */ tab: PluginTabRecord; @@ -1118,6 +1138,14 @@ export interface TermixApp extends TermixAppInfo { component: ComponentType, options?: CompanionOptions, ) => Disposer; + /** + * Lets a plugin decide, per host, how the panel bar looks on one tab type. + * Read from the host payload, so it must not call hooks. + */ + registerCompanionLayout: ( + tabType: string, + resolve: (host: PluginHostRecord | undefined) => CompanionLayout, + ) => Disposer; /** Opens a companion on a tab, or closes it when it is showing. */ toggleCompanion: (tab: PluginTabRecord, id?: string) => void; registerHostEditorSection: ( @@ -1347,6 +1375,7 @@ export interface PluginHostBridge { hostProtocols: (host: PluginHostRecord) => string[]; useHostStatus: (hostId: number | undefined) => HostStatusInfo | null; useHostActions: () => HostActionContribution[]; + useCompanions: (tabType: string) => CompanionInfo[]; useActivityTypes: () => string[]; activityTarget: (type: string) => ActivityTargetInfo | undefined; useExtensions: (pointId: string) => ExtensionContribution[]; @@ -1756,6 +1785,14 @@ export function useHostActions(): HostActionContribution[] { return requireHost().useHostActions(); } +/** + * Every panel that can open beside a tab of this type, for a settings screen + * that lets the user hide or reorder them. Reactive. + */ +export function useCompanions(tabType: string): CompanionInfo[] { + return requireHost().useCompanions(tabType); +} + /** * Recent-activity types every registered tab claims (registerTab's * activityTypes), core's and plugins'. For a filter that lists what a user diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index ceb03b693..fa8b77956 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -139,6 +139,7 @@ const offline = () => }), hostProtocols: () => ["ssh"], useHostActions: () => [], + useCompanions: () => [], useActivityTypes: () => [], activityTarget: () => undefined, useExtensions: () => [], diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 0da466299..3ce15fdfc 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -13,6 +13,7 @@ import { registerRailItem } from "@/sidebar/rail-items"; import { registerTabType, type TabRenderProps } from "@/shell/tab-registry"; import { registerCompanionDef, + registerCompanionLayoutDef, toggleCompanion, type CompanionDefaults, type CompanionRenderProps, @@ -265,6 +266,16 @@ export function createPluginApp( ); }, + registerCompanionLayout(tabType, resolve) { + return track( + registerCompanionLayoutDef({ + id: `${pluginId}.${tabType}`, + tabTypes: [tabType], + resolve: resolve as never, + }), + ); + }, + toggleCompanion(tab, id) { toggleCompanion( tab as unknown as Tab, diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 99693c4ae..e43e748cb 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -4,6 +4,7 @@ import { readStatusColorScheme } from "@/hooks/use-status-color-scheme"; import { enabledHostProtocols } from "@/sidebar/host-protocols"; import { useHostActions } from "@/sidebar/host-contributions"; import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry"; +import { useCompanionDefs } from "@/shell/companions"; import { getExtension, useExtensions } from "./extension-registry"; import { useTranslation as useI18nTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -401,6 +402,17 @@ export const pluginHostBridge: PluginHostBridge = { useHostActions: () => useHostActions() as unknown as HostActionContribution[], + useCompanions: (tabType) => { + const defs = useCompanionDefs(); + return defs + .filter((def) => !def.tabTypes?.length || def.tabTypes.includes(tabType)) + .map((def) => ({ + id: def.id, + titleKey: def.titleKey ?? def.id, + icon: def.icon as never, + })); + }, + useActivityTypes: () => { const defs = useTabTypes(); const types = new Set(); diff --git a/src/ui/shell/CompanionFrame.tsx b/src/ui/shell/CompanionFrame.tsx index a6b9a3d3d..74c439116 100644 --- a/src/ui/shell/CompanionFrame.tsx +++ b/src/ui/shell/CompanionFrame.tsx @@ -20,10 +20,13 @@ import { useIsMobile } from "@/hooks/use-mobile"; import { hostToSSHHost } from "@/lib/host-to-ssh-host"; import type { Tab } from "@/types/ui-types"; import { + arrangeCompanions, companionApplies, companionKey, patchCompanionState, + resolveCompanionLayout, useCompanionDefs, + useCompanionLayoutDefs, useCompanionState, type CompanionDef, type CompanionDefaults, @@ -69,11 +72,16 @@ export const CompanionFrame = memo(function CompanionFrame({ children: ReactNode; }) { const defs = useCompanionDefs(); + const layoutDefs = useCompanionLayoutDefs(); const applicable = useMemo( - () => defs.filter((def) => companionApplies(def, tab)), + () => + arrangeCompanions( + defs.filter((def) => companionApplies(def, tab)), + resolveCompanionLayout(tab.type, tab.host).order, + ), // tab.host and tab.type decide it // eslint-disable-next-line react-hooks/exhaustive-deps - [defs, tab.type, tab.host], + [defs, layoutDefs, tab.type, tab.host], ); if (applicable.length === 0) return <>{children}; return ( @@ -142,9 +150,13 @@ function CompanionLayout({ }, [active]); const activeDefaults = active ? defaults[active.id] : undefined; + const layout = useMemo( + () => resolveCompanionLayout(tab.type, tab.host), + [tab.type, tab.host], + ); const side: CompanionSide = isMobile ? "bottom" - : (state.side ?? activeDefaults?.side ?? "right"); + : (state.side ?? layout.side ?? activeDefaults?.side ?? "right"); const baseSize = state.size ?? activeDefaults?.size ?? diff --git a/src/ui/shell/companions.ts b/src/ui/shell/companions.ts index 51b6a2be9..a9c3e5563 100644 --- a/src/ui/shell/companions.ts +++ b/src/ui/shell/companions.ts @@ -48,12 +48,64 @@ export const listCompanionDefs = registry.list; export const useCompanionDefs = registry.useList; export const resetCompanions = registry.reset; +/** How a host wants its panel bar: on or off, where, which buttons in what order. */ +export interface CompanionLayout { + enabled?: boolean; + side?: CompanionSide; + order?: string[]; + hidden?: string[]; +} + +export interface CompanionLayoutDef { + id: string; + tabTypes: string[]; + resolve: (host: Host | undefined) => CompanionLayout; +} + +const layouts = createRegistry(); + +export const registerCompanionLayoutDef = layouts.register; +export const useCompanionLayoutDefs = layouts.useList; +export const resetCompanionLayouts = layouts.reset; + +export function resolveCompanionLayout( + tabType: string, + host: Host | undefined, +): CompanionLayout { + const def = layouts.list().find((entry) => entry.tabTypes.includes(tabType)); + if (!def) return {}; + try { + return def.resolve(host) ?? {}; + } catch { + return {}; + } +} + export function companionApplies(def: CompanionDef, tab: Tab): boolean { if (!tab.host) return false; if (def.tabTypes?.length && !def.tabTypes.includes(tab.type)) return false; + const layout = resolveCompanionLayout(tab.type, tab.host); + if (layout.enabled === false || layout.hidden?.includes(def.id)) return false; return def.when ? def.when(tab, tab.host) : true; } +/** Orders defs by a saved layout. Ones it has not seen keep their place after. */ +export function arrangeCompanions( + defs: T[], + order: string[] | undefined, +): T[] { + if (!order?.length) return defs; + const rank = new Map(order.map((id, index) => [id, index])); + return defs + .map((def, index) => ({ def, index })) + .sort( + (a, b) => + (rank.get(a.def.id) ?? order.length + a.index) - + (rank.get(b.def.id) ?? order.length + b.index), + ) + .map(({ def }) => def); +} + export interface CompanionTabState { /** The companion shown, or null when closed. */ openId: string | null; diff --git a/src/ui/tests/shell/companions.test.tsx b/src/ui/tests/shell/companions.test.tsx index 4cec3facc..b381aca48 100644 --- a/src/ui/tests/shell/companions.test.tsx +++ b/src/ui/tests/shell/companions.test.tsx @@ -2,9 +2,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import type { Tab } from "@/types/ui-types"; import { + arrangeCompanions, companionApplies, getCompanionState, registerCompanionDef, + registerCompanionLayoutDef, + resetCompanionLayouts, resetCompanions, resetCompanionStates, toggleCompanion, @@ -74,3 +77,35 @@ describe("companions", () => { expect(getCompanionState("t1").openId).toBeNull(); }); }); + +describe("companion layout", () => { + it("hides a companion and the whole bar by host layout", () => { + registerCompanionLayoutDef({ + id: "layout", + tabTypes: ["terminal"], + resolve: (host) => + (host as unknown as { off?: boolean }).off + ? { enabled: false } + : { hidden: ["b"] }, + }); + const a = { id: "a", component: () => null }; + const b = { id: "b", component: () => null }; + expect(companionApplies(a, tab())).toBe(true); + expect(companionApplies(b, tab())).toBe(false); + expect( + companionApplies(a, tab({ host: { id: 1, off: true } as never })), + ).toBe(false); + resetCompanionLayouts(); + expect(companionApplies(b, tab())).toBe(true); + }); + + it("orders by the saved list and keeps unknown ones after", () => { + const defs = [{ id: "a" }, { id: "b" }, { id: "c" }]; + expect(arrangeCompanions(defs, ["c", "a"]).map((d) => d.id)).toEqual([ + "c", + "a", + "b", + ]); + expect(arrangeCompanions(defs, undefined)).toBe(defs); + }); +});