mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: add companion panels beside host tabs
This commit is contained in:
1 parent
3b3fd7f135
commit
802b3003d5
12 files changed
+706
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<TabProps>,
|
||||
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<CompanionProps>,
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<CompanionRenderProps>,
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -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 ? <Reader onValue={report} /> : 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 (
|
||||
<CompanionLayout tab={tab} defs={applicable} isVisible={isVisible}>
|
||||
{children}
|
||||
</CompanionLayout>
|
||||
);
|
||||
});
|
||||
|
||||
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<Record<string, CompanionDefaults>>(
|
||||
{},
|
||||
);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// A companion stays mounted once opened, so it keeps its place and session.
|
||||
const [mounted, setMounted] = useState<Set<string>>(new Set());
|
||||
const rootRef = useRef<HTMLDivElement>(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<string, unknown> | null) ?? null,
|
||||
[tab.terminalRef],
|
||||
);
|
||||
const sshHost = useMemo(
|
||||
() =>
|
||||
tab.host
|
||||
? (hostToSSHHost(tab.host) as unknown as Record<string, unknown>)
|
||||
: 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 (
|
||||
<div className="flex h-full w-full min-h-0 min-w-0">
|
||||
{defs.map((def) =>
|
||||
def.Defaults ? (
|
||||
<DefaultsReader key={def.id} def={def} onValue={onDefaults} />
|
||||
) : null,
|
||||
)}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"relative flex flex-1 min-h-0 min-w-0",
|
||||
vertical
|
||||
? "flex-col"
|
||||
: side === "left"
|
||||
? "flex-row-reverse"
|
||||
: "flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 min-w-0">{children}</div>
|
||||
{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 (
|
||||
<div
|
||||
key={def.id}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 flex-col min-h-0 min-w-0 bg-background border-border",
|
||||
open ? "flex" : "hidden",
|
||||
vertical
|
||||
? "border-t"
|
||||
: side === "left"
|
||||
? "border-r"
|
||||
: "border-l",
|
||||
isMobile && "absolute inset-x-0 bottom-0 z-20 shadow-lg",
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={t("companion.resize")}
|
||||
onMouseDown={startResize}
|
||||
className={cn(
|
||||
"absolute z-10",
|
||||
vertical
|
||||
? "inset-x-0 -top-1 h-2 cursor-row-resize"
|
||||
: side === "left"
|
||||
? "inset-y-0 -right-1 w-2 cursor-col-resize"
|
||||
: "inset-y-0 -left-1 w-2 cursor-col-resize",
|
||||
dragging && "bg-primary/30",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 h-8 px-2 border-b border-border flex-shrink-0">
|
||||
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{def.titleKey ? t(def.titleKey) : def.id}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("companion.moveSide")}
|
||||
onClick={() =>
|
||||
setSide(
|
||||
side === "right"
|
||||
? "bottom"
|
||||
: side === "bottom"
|
||||
? "left"
|
||||
: "right",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SideIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
title={t("companion.close")}
|
||||
onClick={close}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<Component
|
||||
tab={tab}
|
||||
host={tab.host}
|
||||
sshHost={sshHost}
|
||||
getHandle={getHandle}
|
||||
side={side}
|
||||
isVisible={isVisible && open}
|
||||
close={close}
|
||||
setSide={setSide}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showEdge.length > 0 && !isMobile && (
|
||||
<div className="flex flex-col flex-shrink-0 w-6 items-center gap-1 pt-1 border-l border-border bg-background">
|
||||
{showEdge.map((def) => {
|
||||
const Icon = def.icon ?? PanelRight;
|
||||
const open = active?.id === def.id;
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
title={def.titleKey ? t(def.titleKey) : def.id}
|
||||
aria-pressed={open}
|
||||
onClick={() =>
|
||||
patchCompanionState(key, {
|
||||
seen: true,
|
||||
openId: open ? null : def.id,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"p-1 text-muted-foreground hover:text-foreground",
|
||||
open && "text-foreground bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
/** The tab's surface handle, read at call time. */
|
||||
getHandle: () => Record<string, unknown> | 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<CompanionRenderProps>;
|
||||
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<CompanionDef>(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<string, CompanionTabState>();
|
||||
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<string, CompanionTabState>;
|
||||
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<CompanionTabState>,
|
||||
): 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();
|
||||
}
|
||||
@@ -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 ? <PanelTabFrame>{content}</PanelTabFrame> : content,
|
||||
<CompanionFrame tab={tab} isVisible={isVisible}>
|
||||
{def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content}
|
||||
</CompanionFrame>,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Tab> = {}) =>
|
||||
({
|
||||
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(
|
||||
<CompanionFrame tab={tab()} isVisible>
|
||||
<div>content</div>
|
||||
</CompanionFrame>,
|
||||
);
|
||||
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: () => <div>panel body</div>,
|
||||
});
|
||||
render(
|
||||
<CompanionFrame tab={tab()} isVisible>
|
||||
<div>content</div>
|
||||
</CompanionFrame>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user