feat: per host layout for the terminal panel bar

This commit is contained in:
LukeGus committed 2026-10-20 11:52:22 -05:00
1 parent d8c686177b
commit ea50dc349a
9 files changed
+165 -3

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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) |
+37
View File
@@ -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<CompanionProps>,
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
+1
View File
@@ -139,6 +139,7 @@ const offline = () =>
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useCompanions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
+11
View File
@@ -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,
+12
View File
@@ -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<string>();
+15 -3
View File
@@ -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 ??
+52
View File
@@ -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<CompanionLayoutDef>();
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<T extends { id: string }>(
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;
+35
View File
@@ -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);
});
});