From 802b3003d508e0d93fb32e37e2a6718b6ee9a33c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 19 Oct 2026 21:46:02 -0500 Subject: [PATCH] feat: add companion panels beside host tabs --- CHANGELOG.md | 1 + packages/plugin-sdk/CHANGELOG.md | 8 + packages/plugin-sdk/PLUGIN-GUIDE.md | 3 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/frontend.ts | 52 ++++ packages/plugin-sdk/src/manifest.ts | 2 +- src/ui/locales/en.json | 5 + src/ui/plugin-host/app.ts | 49 +++- src/ui/shell/CompanionFrame.tsx | 345 +++++++++++++++++++++++++ src/ui/shell/companions.ts | 163 ++++++++++++ src/ui/shell/tabUtils.tsx | 5 +- src/ui/tests/shell/companions.test.tsx | 76 ++++++ 12 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 src/ui/shell/CompanionFrame.tsx create mode 100644 src/ui/shell/companions.ts create mode 100644 src/ui/tests/shell/companions.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f093366b1..5348fdd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user - FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine - Pasting a security key file now explains how to use it instead of failing to decode it +- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it ### Changed diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index a746d65b8..2bd566464 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.17 + +Plugin API 1.9.0. + +### Added + +- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running + ## 1.0.16 Plugin API 1.8.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index d03522a36..5553f7d24 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.16 | Plugin API 1.8.0 +SDK 1.0.17 | Plugin API 1.9.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -495,6 +495,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 | | `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/package.json b/packages/plugin-sdk/package.json index 640bcaac7..7cb15ca04 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.16", + "version": "1.0.17", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 8f7c06c75..efe58c567 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -814,6 +814,46 @@ export interface SettingsComponentProps { running: boolean; } +/** What a companion starts with. Read from the plugin's own settings. */ +export interface CompanionDefaults { + /** Open by itself the first time a matching tab shows. */ + open?: boolean; + side?: "left" | "right" | "bottom"; + /** Pixels of width, or height at the bottom. */ + size?: number; + /** Show the edge button that opens and closes it. Default true. */ + edgeButton?: boolean; +} + +export interface CompanionProps { + /** The tab it sits beside. */ + tab: PluginTabRecord; + host?: PluginHostRecord; + /** The host in the shape the connection APIs take. */ + sshHost?: Record; + /** The tab's surface handle (see TabHandle), read at call time. */ + getHandle: () => TabHandle | null; + side: "left" | "right" | "bottom"; + /** False while the tab is in the background or the panel is closed. */ + isVisible: boolean; + close: () => void; + setSide: (side: "left" | "right" | "bottom") => void; +} + +export interface CompanionOptions { + icon?: IconComponent; + titleKey?: string; + order?: number; + /** Tab types it applies to. Any tab with a host when empty. */ + tabTypes?: string[]; + when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean; + /** + * A hook returning the starting state. It runs in the plugin's scope, so it + * can read the plugin's settings. + */ + useDefaults?: () => CompanionDefaults; +} + export type ActionHandler = (...args: never[]) => unknown; export interface ActionSlotDefinition { @@ -1068,6 +1108,18 @@ export interface TermixApp extends TermixAppInfo { component: ComponentType, options?: TabOptions, ) => Disposer; + /** + * A panel that sits beside any host tab the options match (a terminal, a + * desktop, ...), bound to that tab's host. The user opens, resizes and moves + * it; the tab keeps running underneath. + */ + registerCompanion: ( + id: string, + component: ComponentType, + options?: CompanionOptions, + ) => Disposer; + /** Opens a companion on a tab, or closes it when it is showing. */ + toggleCompanion: (tab: PluginTabRecord, id?: string) => void; registerHostEditorSection: ( section: HostEditorSectionContribution, ) => Disposer; diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 54a72430a..8f75161ae 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.8.0"; +export const PLUGIN_API_VERSION = "1.9.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 6d3be80a3..2d19355f9 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -235,6 +235,11 @@ "ready": "Termix {{version}} is ready to install", "readyHint": "It installs the next time Termix restarts." }, + "companion": { + "close": "Close panel", + "moveSide": "Move panel", + "resize": "Resize panel" + }, "nav": { "home": "Home", "terminal": "Terminal", diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 0d8b963c7..0da466299 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -1,4 +1,5 @@ -import type { ComponentType } from "react"; +import { useEffect, type ComponentType } from "react"; +import type { Tab } from "@/types/ui-types"; import type { Disposer, PluginManifest, @@ -10,6 +11,12 @@ import { } from "@/api/plugins-api"; import { registerRailItem } from "@/sidebar/rail-items"; import { registerTabType, type TabRenderProps } from "@/shell/tab-registry"; +import { + registerCompanionDef, + toggleCompanion, + type CompanionDefaults, + type CompanionRenderProps, +} from "@/shell/companions"; import { registerPanel, type PanelRenderProps } from "@/shell/panel-registry"; import { registerHostEditorSection, @@ -225,6 +232,46 @@ export function createPluginApp( ); }, + registerCompanion(id, component, options = {}) { + const useDefaults = options.useDefaults; + const Defaults = useDefaults + ? scoped(function CompanionDefaultsReader({ + onValue, + }: { + onValue: (value: CompanionDefaults) => void; + }) { + const value = useDefaults(); + const { open, side, size, edgeButton } = value; + useEffect(() => { + onValue({ open, side, size, edgeButton }); + }, [onValue, open, side, size, edgeButton]); + return null; + }) + : undefined; + return track( + registerCompanionDef({ + id: `${pluginId}.${id}`, + pluginId, + component: scoped( + component as unknown as ComponentType, + ), + icon: withIconBoundary(pluginId, options.icon as never) as never, + titleKey: options.titleKey ? key(options.titleKey) : undefined, + order: options.order, + tabTypes: options.tabTypes, + when: options.when as never, + Defaults, + }), + ); + }, + + toggleCompanion(tab, id) { + toggleCompanion( + tab as unknown as Tab, + id ? `${pluginId}.${id}` : undefined, + ); + }, + registerTab(type, component, options = {}) { requireDeclared("tab", type, "tab type"); return track( diff --git a/src/ui/shell/CompanionFrame.tsx b/src/ui/shell/CompanionFrame.tsx new file mode 100644 index 000000000..a6b9a3d3d --- /dev/null +++ b/src/ui/shell/CompanionFrame.tsx @@ -0,0 +1,345 @@ +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowDownToLine, + ArrowLeftToLine, + ArrowRightToLine, + PanelRight, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { hostToSSHHost } from "@/lib/host-to-ssh-host"; +import type { Tab } from "@/types/ui-types"; +import { + companionApplies, + companionKey, + patchCompanionState, + useCompanionDefs, + useCompanionState, + type CompanionDef, + type CompanionDefaults, + type CompanionSide, +} from "./companions"; + +const DEFAULT_SIDE_SIZE = 360; +const DEFAULT_BOTTOM_SIZE = 280; +const MIN_SIZE = 200; + +function DefaultsReader({ + def, + onValue, +}: { + def: CompanionDef; + onValue: (id: string, value: CompanionDefaults) => void; +}) { + const Reader = def.Defaults; + const report = useCallback( + (value: CompanionDefaults) => onValue(def.id, value), + [def.id, onValue], + ); + return Reader ? : null; +} + +function sideIcon(side: CompanionSide) { + if (side === "left") return ArrowLeftToLine; + if (side === "bottom") return ArrowDownToLine; + return ArrowRightToLine; +} + +/** + * Puts a pane beside a tab for the companions that apply to it. The tab keeps + * its place in the tree, so opening or closing a companion never remounts it. + */ +export const CompanionFrame = memo(function CompanionFrame({ + tab, + isVisible, + children, +}: { + tab: Tab; + isVisible: boolean; + children: ReactNode; +}) { + const defs = useCompanionDefs(); + const applicable = useMemo( + () => defs.filter((def) => companionApplies(def, tab)), + // tab.host and tab.type decide it + // eslint-disable-next-line react-hooks/exhaustive-deps + [defs, tab.type, tab.host], + ); + if (applicable.length === 0) return <>{children}; + return ( + + {children} + + ); +}); + +function CompanionLayout({ + tab, + defs, + isVisible, + children, +}: { + tab: Tab; + defs: CompanionDef[]; + isVisible: boolean; + children: ReactNode; +}) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const key = companionKey(tab); + const state = useCompanionState(key); + const [defaults, setDefaults] = useState>( + {}, + ); + const [dragging, setDragging] = useState(false); + // A companion stays mounted once opened, so it keeps its place and session. + const [mounted, setMounted] = useState>(new Set()); + const rootRef = useRef(null); + + const onDefaults = useCallback((id: string, value: CompanionDefaults) => { + setDefaults((prev) => { + const old = prev[id]; + if ( + old && + old.open === value.open && + old.side === value.side && + old.size === value.size && + old.edgeButton === value.edgeButton + ) { + return prev; + } + return { ...prev, [id]: value }; + }); + }, []); + + // The first time a tab shows, a companion can open itself. + useEffect(() => { + if (state.seen || !isVisible) return; + const ready = defs.every( + (def) => !def.Defaults || defaults[def.id] !== undefined, + ); + if (!ready) return; + const auto = defs.find((def) => defaults[def.id]?.open); + patchCompanionState(key, { seen: true, openId: auto?.id ?? null }); + }, [state.seen, isVisible, defs, defaults, key]); + + const active = defs.find((def) => def.id === state.openId) ?? null; + useEffect(() => { + if (!active) return; + setMounted((prev) => + prev.has(active.id) ? prev : new Set(prev).add(active.id), + ); + }, [active]); + + const activeDefaults = active ? defaults[active.id] : undefined; + const side: CompanionSide = isMobile + ? "bottom" + : (state.side ?? activeDefaults?.side ?? "right"); + const baseSize = + state.size ?? + activeDefaults?.size ?? + (side === "bottom" ? DEFAULT_BOTTOM_SIZE : DEFAULT_SIDE_SIZE); + + const setSide = useCallback( + (next: CompanionSide) => patchCompanionState(key, { side: next }), + [key], + ); + const close = useCallback( + () => patchCompanionState(key, { openId: null }), + [key], + ); + + const startResize = (event: React.MouseEvent) => { + event.preventDefault(); + const root = rootRef.current; + if (!root) return; + const rect = root.getBoundingClientRect(); + const total = side === "bottom" ? rect.height : rect.width; + const max = Math.max(MIN_SIZE, total - MIN_SIZE); + setDragging(true); + const move = (e: MouseEvent) => { + const raw = + side === "right" + ? rect.right - e.clientX + : side === "left" + ? e.clientX - rect.left + : rect.bottom - e.clientY; + patchCompanionState(key, { + size: Math.round(Math.min(max, Math.max(MIN_SIZE, raw))), + }); + }; + const up = () => { + setDragging(false); + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + }; + + const getHandle = useCallback( + () => (tab.terminalRef?.current as Record | null) ?? null, + [tab.terminalRef], + ); + const sshHost = useMemo( + () => + tab.host + ? (hostToSSHHost(tab.host) as unknown as Record) + : undefined, + [tab.host], + ); + + const showEdge = defs.filter((def) => defaults[def.id]?.edgeButton !== false); + const vertical = side === "bottom"; + const clamped = vertical ? Math.min(baseSize, 2000) : baseSize; + const paneStyle = isMobile + ? { height: "55%" } + : vertical + ? { height: clamped } + : { width: clamped }; + const SideIcon = sideIcon(side); + + return ( +
+ {defs.map((def) => + def.Defaults ? ( + + ) : null, + )} +
+
{children}
+ {defs.map((def) => { + if (!mounted.has(def.id)) return null; + const open = active?.id === def.id; + const Component = def.component; + const Icon = def.icon; + return ( +
+ {!isMobile && ( +
+ )} +
+ {Icon && } + + {def.titleKey ? t(def.titleKey) : def.id} + + {!isMobile && ( + + )} + +
+
+ +
+
+ ); + })} +
+ {showEdge.length > 0 && !isMobile && ( +
+ {showEdge.map((def) => { + const Icon = def.icon ?? PanelRight; + const open = active?.id === def.id; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/ui/shell/companions.ts b/src/ui/shell/companions.ts new file mode 100644 index 000000000..51b6a2be9 --- /dev/null +++ b/src/ui/shell/companions.ts @@ -0,0 +1,163 @@ +import { useSyncExternalStore, type ComponentType } from "react"; +import type { Host, Tab } from "@/types/ui-types"; +import { byOrderThenId, createRegistry } from "@/lib/registry"; + +export type CompanionSide = "left" | "right" | "bottom"; + +export interface CompanionDefaults { + /** Open by itself the first time a matching tab shows. */ + open?: boolean; + side?: CompanionSide; + /** Pixels of width, or height at the bottom. */ + size?: number; + /** Show the edge button that opens and closes it. */ + edgeButton?: boolean; +} + +export interface CompanionRenderProps { + tab: Tab; + host?: Host; + sshHost?: Record; + /** The tab's surface handle, read at call time. */ + getHandle: () => Record | null; + side: CompanionSide; + isVisible: boolean; + close: () => void; + setSide: (side: CompanionSide) => void; +} + +export interface CompanionDef { + id: string; + pluginId?: string; + titleKey?: string; + icon?: ComponentType<{ className?: string }>; + component: ComponentType; + order?: number; + /** Tab types it applies to. Any tab with a host when empty. */ + tabTypes?: string[]; + when?: (tab: Tab, host?: Host) => boolean; + /** Renders nothing and reports the defaults, inside the plugin's scope. */ + Defaults?: ComponentType<{ onValue: (value: CompanionDefaults) => void }>; +} + +const registry = createRegistry(byOrderThenId); + +export const registerCompanionDef = registry.register; +export const getCompanionDef = registry.get; +export const listCompanionDefs = registry.list; +export const useCompanionDefs = registry.useList; +export const resetCompanions = registry.reset; + +export function companionApplies(def: CompanionDef, tab: Tab): boolean { + if (!tab.host) return false; + if (def.tabTypes?.length && !def.tabTypes.includes(tab.type)) return false; + return def.when ? def.when(tab, tab.host) : true; +} + +export interface CompanionTabState { + /** The companion shown, or null when closed. */ + openId: string | null; + /** Set once the defaults ran, so closing it is not undone. */ + seen: boolean; + side?: CompanionSide; + size?: number; +} + +const STORAGE_KEY = "termix:companions"; +const EMPTY: CompanionTabState = { openId: null, seen: false }; + +let states = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +function load(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + states = new Map(Object.entries(parsed)); + } catch { + // storage unavailable or corrupt + } +} + +function save(): void { + try { + const entries = [...states.entries()].slice(-200); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(Object.fromEntries(entries)), + ); + } catch { + // storage unavailable + } +} + +load(); + +/** Open state follows the tab through a restore. */ +export function companionKey(tab: Tab): string { + return tab.instanceId ?? tab.id; +} + +export function getCompanionState(key: string): CompanionTabState { + return states.get(key) ?? EMPTY; +} + +export function patchCompanionState( + key: string, + patch: Partial, +): void { + const next = { ...getCompanionState(key), ...patch }; + states = new Map(states); + states.delete(key); + states.set(key, next); + version += 1; + save(); + for (const listener of listeners) listener(); +} + +export function forgetCompanionState(key: string): void { + if (!states.has(key)) return; + states = new Map(states); + states.delete(key); + version += 1; + save(); + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useCompanionState(key: string): CompanionTabState { + useSyncExternalStore( + subscribe, + () => version, + () => version, + ); + return getCompanionState(key); +} + +/** Opens a companion, or closes it when it is the one showing. */ +export function toggleCompanion(tab: Tab, id?: string): void { + const key = companionKey(tab); + const current = getCompanionState(key); + const target = + id ?? + current.openId ?? + listCompanionDefs().find((def) => companionApplies(def, tab))?.id ?? + null; + if (!target) return; + patchCompanionState(key, { + seen: true, + openId: current.openId === target ? null : target, + }); +} + +export function resetCompanionStates(): void { + states = new Map(); + version += 1; + for (const listener of listeners) listener(); +} diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 82a1b25a4..3ec9fb522 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -12,6 +12,7 @@ import { type TabTypeDef, } from "./tab-registry"; import { getPanel, type PanelDef } from "./panel-registry"; +import { CompanionFrame } from "./CompanionFrame"; import { markAdaptiveResourceUsed, runAdaptiveBackgroundTask, @@ -144,7 +145,9 @@ const RegisteredTab = memo(function RegisteredTab({ /> ); return withTabSuspense( - def.panelFrame ? {content} : content, + + {def.panelFrame ? {content} : content} + , ); }); diff --git a/src/ui/tests/shell/companions.test.tsx b/src/ui/tests/shell/companions.test.tsx new file mode 100644 index 000000000..4cec3facc --- /dev/null +++ b/src/ui/tests/shell/companions.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { Tab } from "@/types/ui-types"; +import { + companionApplies, + getCompanionState, + registerCompanionDef, + resetCompanions, + resetCompanionStates, + toggleCompanion, +} from "../../shell/companions"; +import { CompanionFrame } from "../../shell/CompanionFrame"; + +const tab = (extra: Partial = {}) => + ({ + id: "t1", + type: "terminal", + label: "box", + host: { id: 1 }, + ...extra, + }) as unknown as Tab; + +afterEach(() => { + resetCompanions(); + resetCompanionStates(); +}); + +describe("companions", () => { + it("applies by tab type and host", () => { + const def = { + id: "a", + component: () => null, + tabTypes: ["terminal"], + }; + expect(companionApplies(def, tab())).toBe(true); + expect(companionApplies(def, tab({ type: "files" }))).toBe(false); + expect(companionApplies(def, tab({ host: undefined }))).toBe(false); + }); + + it("toggles open and closed, defaulting to the first match", () => { + registerCompanionDef({ id: "a", component: () => null }); + toggleCompanion(tab()); + expect(getCompanionState("t1").openId).toBe("a"); + toggleCompanion(tab(), "a"); + expect(getCompanionState("t1").openId).toBeNull(); + }); + + it("renders only the tab when nothing applies", () => { + render( + +
content
+
, + ); + expect(screen.getByText("content")).toBeTruthy(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("shows the panel from the edge button and keeps the tab mounted", () => { + registerCompanionDef({ + id: "a", + titleKey: "a.title", + component: () =>
panel body
, + }); + render( + +
content
+
, + ); + expect(screen.queryByText("panel body")).toBeNull(); + fireEvent.click(screen.getByTitle("a.title")); + expect(screen.getByText("panel body")).toBeTruthy(); + expect(screen.getByText("content")).toBeTruthy(); + act(() => toggleCompanion(tab(), "a")); + expect(getCompanionState("t1").openId).toBeNull(); + }); +});