mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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
|
### 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
|
- `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.0.17
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ your plugin's error boundary and Suspense.
|
|||||||
| `registerRailItem` | A sidebar rail button |
|
| `registerRailItem` | A sidebar rail button |
|
||||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
| `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 |
|
| `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`) |
|
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||||
| `registerHostAction` | A host button, palette entry or default connect |
|
| `registerHostAction` | A host button, palette entry or default connect |
|
||||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||||
|
|||||||
@@ -825,6 +825,26 @@ export interface CompanionDefaults {
|
|||||||
edgeButton?: boolean;
|
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 {
|
export interface CompanionProps {
|
||||||
/** The tab it sits beside. */
|
/** The tab it sits beside. */
|
||||||
tab: PluginTabRecord;
|
tab: PluginTabRecord;
|
||||||
@@ -1118,6 +1138,14 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
component: ComponentType<CompanionProps>,
|
component: ComponentType<CompanionProps>,
|
||||||
options?: CompanionOptions,
|
options?: CompanionOptions,
|
||||||
) => Disposer;
|
) => 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. */
|
/** Opens a companion on a tab, or closes it when it is showing. */
|
||||||
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
||||||
registerHostEditorSection: (
|
registerHostEditorSection: (
|
||||||
@@ -1347,6 +1375,7 @@ export interface PluginHostBridge {
|
|||||||
hostProtocols: (host: PluginHostRecord) => string[];
|
hostProtocols: (host: PluginHostRecord) => string[];
|
||||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||||
useHostActions: () => HostActionContribution[];
|
useHostActions: () => HostActionContribution[];
|
||||||
|
useCompanions: (tabType: string) => CompanionInfo[];
|
||||||
useActivityTypes: () => string[];
|
useActivityTypes: () => string[];
|
||||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||||
useExtensions: (pointId: string) => ExtensionContribution[];
|
useExtensions: (pointId: string) => ExtensionContribution[];
|
||||||
@@ -1756,6 +1785,14 @@ export function useHostActions(): HostActionContribution[] {
|
|||||||
return requireHost().useHostActions();
|
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
|
* Recent-activity types every registered tab claims (registerTab's
|
||||||
* activityTypes), core's and plugins'. For a filter that lists what a user
|
* activityTypes), core's and plugins'. For a filter that lists what a user
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ const offline = () =>
|
|||||||
}),
|
}),
|
||||||
hostProtocols: () => ["ssh"],
|
hostProtocols: () => ["ssh"],
|
||||||
useHostActions: () => [],
|
useHostActions: () => [],
|
||||||
|
useCompanions: () => [],
|
||||||
useActivityTypes: () => [],
|
useActivityTypes: () => [],
|
||||||
activityTarget: () => undefined,
|
activityTarget: () => undefined,
|
||||||
useExtensions: () => [],
|
useExtensions: () => [],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { registerRailItem } from "@/sidebar/rail-items";
|
|||||||
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
|
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
|
||||||
import {
|
import {
|
||||||
registerCompanionDef,
|
registerCompanionDef,
|
||||||
|
registerCompanionLayoutDef,
|
||||||
toggleCompanion,
|
toggleCompanion,
|
||||||
type CompanionDefaults,
|
type CompanionDefaults,
|
||||||
type CompanionRenderProps,
|
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, id) {
|
||||||
toggleCompanion(
|
toggleCompanion(
|
||||||
tab as unknown as Tab,
|
tab as unknown as Tab,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
|
|||||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||||
import { useHostActions } from "@/sidebar/host-contributions";
|
import { useHostActions } from "@/sidebar/host-contributions";
|
||||||
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
|
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
|
||||||
|
import { useCompanionDefs } from "@/shell/companions";
|
||||||
import { getExtension, useExtensions } from "./extension-registry";
|
import { getExtension, useExtensions } from "./extension-registry";
|
||||||
import { useTranslation as useI18nTranslation } from "react-i18next";
|
import { useTranslation as useI18nTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -401,6 +402,17 @@ export const pluginHostBridge: PluginHostBridge = {
|
|||||||
|
|
||||||
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
|
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: () => {
|
useActivityTypes: () => {
|
||||||
const defs = useTabTypes();
|
const defs = useTabTypes();
|
||||||
const types = new Set<string>();
|
const types = new Set<string>();
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
|||||||
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
|
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
|
||||||
import type { Tab } from "@/types/ui-types";
|
import type { Tab } from "@/types/ui-types";
|
||||||
import {
|
import {
|
||||||
|
arrangeCompanions,
|
||||||
companionApplies,
|
companionApplies,
|
||||||
companionKey,
|
companionKey,
|
||||||
patchCompanionState,
|
patchCompanionState,
|
||||||
|
resolveCompanionLayout,
|
||||||
useCompanionDefs,
|
useCompanionDefs,
|
||||||
|
useCompanionLayoutDefs,
|
||||||
useCompanionState,
|
useCompanionState,
|
||||||
type CompanionDef,
|
type CompanionDef,
|
||||||
type CompanionDefaults,
|
type CompanionDefaults,
|
||||||
@@ -69,11 +72,16 @@ export const CompanionFrame = memo(function CompanionFrame({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const defs = useCompanionDefs();
|
const defs = useCompanionDefs();
|
||||||
|
const layoutDefs = useCompanionLayoutDefs();
|
||||||
const applicable = useMemo(
|
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
|
// tab.host and tab.type decide it
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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}</>;
|
if (applicable.length === 0) return <>{children}</>;
|
||||||
return (
|
return (
|
||||||
@@ -142,9 +150,13 @@ function CompanionLayout({
|
|||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const activeDefaults = active ? defaults[active.id] : undefined;
|
const activeDefaults = active ? defaults[active.id] : undefined;
|
||||||
|
const layout = useMemo(
|
||||||
|
() => resolveCompanionLayout(tab.type, tab.host),
|
||||||
|
[tab.type, tab.host],
|
||||||
|
);
|
||||||
const side: CompanionSide = isMobile
|
const side: CompanionSide = isMobile
|
||||||
? "bottom"
|
? "bottom"
|
||||||
: (state.side ?? activeDefaults?.side ?? "right");
|
: (state.side ?? layout.side ?? activeDefaults?.side ?? "right");
|
||||||
const baseSize =
|
const baseSize =
|
||||||
state.size ??
|
state.size ??
|
||||||
activeDefaults?.size ??
|
activeDefaults?.size ??
|
||||||
|
|||||||
@@ -48,12 +48,64 @@ export const listCompanionDefs = registry.list;
|
|||||||
export const useCompanionDefs = registry.useList;
|
export const useCompanionDefs = registry.useList;
|
||||||
export const resetCompanions = registry.reset;
|
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 {
|
export function companionApplies(def: CompanionDef, tab: Tab): boolean {
|
||||||
if (!tab.host) return false;
|
if (!tab.host) return false;
|
||||||
if (def.tabTypes?.length && !def.tabTypes.includes(tab.type)) 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;
|
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 {
|
export interface CompanionTabState {
|
||||||
/** The companion shown, or null when closed. */
|
/** The companion shown, or null when closed. */
|
||||||
openId: string | null;
|
openId: string | null;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type { Tab } from "@/types/ui-types";
|
import type { Tab } from "@/types/ui-types";
|
||||||
import {
|
import {
|
||||||
|
arrangeCompanions,
|
||||||
companionApplies,
|
companionApplies,
|
||||||
getCompanionState,
|
getCompanionState,
|
||||||
registerCompanionDef,
|
registerCompanionDef,
|
||||||
|
registerCompanionLayoutDef,
|
||||||
|
resetCompanionLayouts,
|
||||||
resetCompanions,
|
resetCompanions,
|
||||||
resetCompanionStates,
|
resetCompanionStates,
|
||||||
toggleCompanion,
|
toggleCompanion,
|
||||||
@@ -74,3 +77,35 @@ describe("companions", () => {
|
|||||||
expect(getCompanionState("t1").openId).toBeNull();
|
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