mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: per host layout for the terminal panel bar
This commit is contained in:
1 parent
d8c686177b
commit
ea50dc349a
9 files changed
+165
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -139,6 +139,7 @@ const offline = () =>
|
||||
}),
|
||||
hostProtocols: () => ["ssh"],
|
||||
useHostActions: () => [],
|
||||
useCompanions: () => [],
|
||||
useActivityTypes: () => [],
|
||||
activityTarget: () => undefined,
|
||||
useExtensions: () => [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user