mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
This commit is contained in:
1 parent
3656caf168
commit
c77301577f
16 files changed
+473
-52
No files matched your search
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
"network-topology": "^1.0.0"
|
||||
"network-topology": "^1.0.0",
|
||||
"snippets": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -43,6 +44,11 @@
|
||||
"service": "network-topology.graph",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "snippets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
|
||||
@@ -51,3 +51,119 @@ export async function getNetworkTopology(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SnippetSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
isNote: boolean;
|
||||
folder: string | null;
|
||||
}
|
||||
|
||||
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
|
||||
interface SnippetsAccess {
|
||||
list: () => Promise<SnippetSummary[]>;
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
update: (
|
||||
id: number,
|
||||
changes: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
) => Promise<void>;
|
||||
remove: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own snippets, or null when the snippets plugin is off or the
|
||||
* user may not use it. Optional: the list_snippets tool disappears without
|
||||
* it, and the propose_*_snippet tools fail with a clear message.
|
||||
*/
|
||||
export async function listSnippets(
|
||||
userId: string,
|
||||
): Promise<SnippetSummary[] | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.get(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the snippets plugin, unlike the read helpers above: a failed
|
||||
* proposal apply must surface as an error the user sees, not silently no-op.
|
||||
*/
|
||||
function requireSnippetsAccess(userId: string): SnippetsAccess {
|
||||
if (!current) {
|
||||
throw new Error("The snippets plugin is disabled");
|
||||
}
|
||||
return current.get<SnippetsAccess>("snippets.access", { userId });
|
||||
}
|
||||
|
||||
export async function createSnippet(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
): Promise<{ id: number; name: string }> {
|
||||
return requireSnippetsAccess(userId).create(input);
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
changes: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
return requireSnippetsAccess(userId).update(id, changes);
|
||||
}
|
||||
|
||||
export async function deleteSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<boolean> {
|
||||
return requireSnippetsAccess(userId).remove(id);
|
||||
}
|
||||
@@ -2,13 +2,18 @@ import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
import {
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
getSnippet,
|
||||
} from "../services.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
@@ -125,18 +130,15 @@ export async function applyProposal(
|
||||
}
|
||||
|
||||
case "propose_create_snippet": {
|
||||
const created = await createCurrentSnippetRepository().createSnippet(
|
||||
userId,
|
||||
{
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
folder: optionalString(payload.folder),
|
||||
} as any,
|
||||
);
|
||||
const created = await createSnippet(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
folder: optionalString(payload.folder),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
|
||||
summary: `Created snippet ${created.name}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,10 +146,7 @@ export async function applyProposal(
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
const existing = await getSnippet(userId, snippetId);
|
||||
if (!existing) throw new Error("Snippet not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
@@ -164,20 +163,13 @@ export async function applyProposal(
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentSnippetRepository().updateSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
updates as any,
|
||||
);
|
||||
await updateSnippet(userId, snippetId, updates);
|
||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const deleted = await createCurrentSnippetRepository().deleteSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
const deleted = await deleteSnippet(userId, snippetId);
|
||||
if (!deleted) throw new Error("Snippet not found");
|
||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
import { listSavedWorkspaces, getNetworkTopology } from "../services.js";
|
||||
import {
|
||||
listSavedWorkspaces,
|
||||
getNetworkTopology,
|
||||
listSnippets,
|
||||
} from "../services.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
@@ -103,15 +106,14 @@ export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "list_snippets",
|
||||
description:
|
||||
"List the user's saved command snippets, including their folder and the command text.",
|
||||
"List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
|
||||
context.userId,
|
||||
);
|
||||
const snippets = await listSnippets(context.userId);
|
||||
if (!snippets) return { unavailable: true, snippets: [] };
|
||||
return {
|
||||
snippets: snippets.map((snippet: any) => ({
|
||||
snippets: snippets.map((snippet) => ({
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"snippets": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"service": "snippets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
@@ -176,11 +173,9 @@ async function runSnippet(
|
||||
step: Extract<Step, { type: "run_snippet" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const snippet = await createCurrentSnippetRepository()
|
||||
.findOwnedById(context.userId, step.snippetId)
|
||||
.catch(() => null);
|
||||
const snippet = await getSnippet(context.userId, step.snippetId);
|
||||
|
||||
if (!snippet) return fail("Snippet not found");
|
||||
if (!snippet) return fail("Snippet not found, or the snippets plugin is off");
|
||||
if (snippet.isNote) return fail("Notes cannot be executed on a host");
|
||||
|
||||
// Template values only ever reach the snippet through inputValues, never by
|
||||
@@ -189,8 +184,9 @@ async function runSnippet(
|
||||
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
const command = resolveSnippetCommand(
|
||||
snippet.content,
|
||||
const command = await resolveSnippetCommandFor(
|
||||
context.userId,
|
||||
step.snippetId,
|
||||
{
|
||||
ip: target.host.ip,
|
||||
username: target.host.username,
|
||||
@@ -199,6 +195,9 @@ async function runSnippet(
|
||||
},
|
||||
inputValues,
|
||||
);
|
||||
if (!command) {
|
||||
return { output: "", error: "Snippet could not be resolved" };
|
||||
}
|
||||
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run snippet on ${target.name}: ${command}` };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./snippets.js";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
startAutomationsService(
|
||||
// The webhook route authenticates on its own per-automation token rather
|
||||
// than a session, which is the point of an inbound webhook.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
|
||||
interface SnippetsAccess {
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null>;
|
||||
resolveCommand: (
|
||||
id: number,
|
||||
vars: { ip?: string; username?: string; port?: number; name?: string },
|
||||
inputValues?: Record<string, string>,
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A snippet's command, resolved against a target host and run as userId.
|
||||
* Null when the snippets plugin is off, the user may not use it, or the
|
||||
* snippet does not exist. Optional: the run_snippet step fails with a clear
|
||||
* message without it, rather than the plugin failing to activate.
|
||||
*/
|
||||
export async function resolveSnippetCommandFor(
|
||||
userId: string,
|
||||
id: number,
|
||||
vars: { ip?: string; username?: string; port?: number; name?: string },
|
||||
inputValues?: Record<string, string>,
|
||||
): Promise<string | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.resolveCommand(id, vars, inputValues);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.get(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface CommandHostVars {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
function replaceVar(content: string, name: string, value?: string): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* $HOST/$USER/$PORT/$NAME/$INPUT_n substitution for an ad hoc fleet command,
|
||||
* typed directly into the "run on fleet" box rather than loaded from a saved
|
||||
* snippet. Mirrors the snippets plugin's own resolveSnippetCommand and the
|
||||
* frontend's src/ui/lib/snippet-variables.ts - kept as a small standalone
|
||||
* copy rather than a cross-plugin dependency for one pure function with no
|
||||
* snippet data of its own to fetch.
|
||||
*/
|
||||
export function resolveCommandVariables(
|
||||
content: string,
|
||||
host: CommandHostVars | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
|
||||
import { resolveCommandVariables } from "./command-variables.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -900,7 +900,7 @@ router.post(
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
const resolvedCommand = resolveSnippetCommand(
|
||||
const resolvedCommand = resolveCommandVariables(
|
||||
command,
|
||||
{
|
||||
ip: fullHost.ip,
|
||||
|
||||
@@ -16,11 +16,20 @@ import {
|
||||
getSSHHosts,
|
||||
type ServerMetrics,
|
||||
} from "@/main-axios.ts";
|
||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
||||
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
|
||||
import type { Snippet } from "@/types/ui-types.ts";
|
||||
|
||||
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
|
||||
// whether a snippet still has $INPUT_n placeholders once host variables are
|
||||
// substituted, so the quick-action dialog knows to ask before running.
|
||||
const SNIPPET_INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
function hasSnippetInputs(content: string): boolean {
|
||||
SNIPPET_INPUT_PATTERN.lastIndex = 0;
|
||||
return SNIPPET_INPUT_PATTERN.test(content);
|
||||
}
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ServerMetrics } from "@/main-axios";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
const SAMPLE = {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TabProps } from "@termix/plugin-sdk/frontend";
|
||||
import { TerminalTabContent } from "@/features/terminal/TerminalTabContent";
|
||||
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||
import type { TerminalHandle } from "@/features/terminal/terminal-types";
|
||||
import { registerSession, setActiveSession } from "./session-registry";
|
||||
|
||||
/**
|
||||
* Wraps the core terminal tab component to register its ref in the session
|
||||
* registry, so terminal.sendToActive/sendToSession (used by the snippets
|
||||
* plugin, among others) can reach a live session without core exposing
|
||||
* terminalRef through the public tab props.
|
||||
*/
|
||||
export function TerminalTabWithRegistry(props: TabProps) {
|
||||
const tabRecord = props.tab as unknown as {
|
||||
id: string;
|
||||
terminalRef?: { current: TerminalHandle | null };
|
||||
};
|
||||
const host = props.host as
|
||||
| {
|
||||
id: string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
| undefined;
|
||||
const registeredRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
|
||||
const tryRegister = () => {
|
||||
if (cancelled || registeredRef.current) return;
|
||||
const ref = tabRecord.terminalRef?.current;
|
||||
if (!ref) {
|
||||
if (attempts++ < 40) setTimeout(tryRegister, 250);
|
||||
return;
|
||||
}
|
||||
registeredRef.current = registerSession(
|
||||
{
|
||||
id: tabRecord.id,
|
||||
hostId: host ? Number(host.id) || null : null,
|
||||
hostName: host?.name,
|
||||
ip: host?.ip,
|
||||
username: host?.username,
|
||||
port: host?.port,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
};
|
||||
tryRegister();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
registeredRef.current?.();
|
||||
registeredRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabRecord.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isFocusedPane) setActiveSession(tabRecord.id);
|
||||
}, [props.isFocusedPane, tabRecord.id]);
|
||||
|
||||
return <TerminalTabContent {...(props as unknown as TabRenderProps)} />;
|
||||
}
|
||||
@@ -5,16 +5,15 @@ import type {
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
TerminalTabContent,
|
||||
loadTerminal,
|
||||
} from "@/features/terminal/TerminalTabContent";
|
||||
import { loadTerminal } from "@/features/terminal/TerminalTabContent";
|
||||
import TerminalApp from "@/features/terminal/TerminalApp";
|
||||
import {
|
||||
TERMINAL_DOCK_SLOT,
|
||||
TERMINAL_OVERLAY_SLOT,
|
||||
TERMINAL_TOOLBAR_SLOT,
|
||||
} from "@/features/terminal/terminal-slots";
|
||||
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
|
||||
import { listSessions, sendToActive, sendToSession } from "./session-registry";
|
||||
|
||||
/** `?view=terminal` full-screen links. */
|
||||
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
@@ -33,7 +32,7 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerTab(
|
||||
"terminal",
|
||||
TerminalTabContent as unknown as ComponentType<TabProps>,
|
||||
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
|
||||
{
|
||||
icon: Terminal,
|
||||
titleKey: "nav.terminal",
|
||||
@@ -63,4 +62,17 @@ export function activate(app: TermixApp): void {
|
||||
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
|
||||
app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] });
|
||||
|
||||
// Lets another plugin (snippets) push text into a live terminal session
|
||||
// without reaching into core's tab state itself.
|
||||
app.registerAction("terminal.listSessions", () => listSessions());
|
||||
app.registerAction("terminal.sendToActive", ((
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
) => sendToActive(text, opts)) as never);
|
||||
app.registerAction("terminal.sendToSession", ((
|
||||
sessionId: string,
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
) => sendToSession(sessionId, text, opts)) as never);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TerminalHandle } from "@/features/terminal/terminal-types";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
hostId: number | null;
|
||||
hostName?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface Session extends TerminalSessionInfo {
|
||||
ref: TerminalHandle;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
let activeId: string | null = null;
|
||||
|
||||
/** Called by the terminal tab wrapper on mount/unmount and focus change. */
|
||||
export function registerSession(
|
||||
info: TerminalSessionInfo,
|
||||
ref: TerminalHandle,
|
||||
): () => void {
|
||||
sessions.set(info.id, { ...info, ref });
|
||||
return () => {
|
||||
sessions.delete(info.id);
|
||||
if (activeId === info.id) activeId = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function setActiveSession(id: string | null): void {
|
||||
activeId = id;
|
||||
}
|
||||
|
||||
export function listSessions(): TerminalSessionInfo[] {
|
||||
return [...sessions.values()].map(({ ref: _ref, ...info }) => info);
|
||||
}
|
||||
|
||||
function send(session: Session, text: string, run?: boolean): void {
|
||||
if (run) session.ref.sendInput(text + "\r");
|
||||
else session.ref.paste(text);
|
||||
}
|
||||
|
||||
export function sendToActive(text: string, opts?: { run?: boolean }): boolean {
|
||||
if (!activeId) return false;
|
||||
const session = sessions.get(activeId);
|
||||
if (!session) return false;
|
||||
send(session, text, opts?.run);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sendToSession(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
send(session, text, opts?.run);
|
||||
return true;
|
||||
}
|
||||
@@ -50,4 +50,27 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers the terminal session actions snippets and other plugins call into", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.actions()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"terminal.listSessions",
|
||||
"terminal.sendToActive",
|
||||
"terminal.sendToSession",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
await expect(
|
||||
rendered.app.invokeAction("terminal.listSessions"),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
rendered.app.invokeAction("terminal.sendToActive", "echo hi", {
|
||||
run: true,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user