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
@@ -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