diff --git a/plugins/snippets/locales/en.json b/plugins/snippets/locales/en.json index 44c199485..d144663ae 100644 --- a/plugins/snippets/locales/en.json +++ b/plugins/snippets/locales/en.json @@ -227,5 +227,9 @@ "failed": "{{name}} failed", "error": "Failed to execute {{name}}" }, - "paletteHintPaste": "Paste in the active terminal tab" + "paletteHintPaste": "Paste in the active terminal tab", + "targetTerminals": "Run in", + "targetTerminalsHint": "optional, else the active tab", + "selectAll": "All", + "selectNone": "None" } diff --git a/plugins/snippets/src/frontend/SnippetsPanel.tsx b/plugins/snippets/src/frontend/SnippetsPanel.tsx index 12017cc97..a4975a288 100644 --- a/plugins/snippets/src/frontend/SnippetsPanel.tsx +++ b/plugins/snippets/src/frontend/SnippetsPanel.tsx @@ -33,6 +33,11 @@ import { } from "@termix/plugin-sdk/ui"; import { createSnippetsApi } from "./snippets-api"; import { useSnippetRunner, type TargetHost } from "./use-snippet-runner"; +import { + TargetTerminals, + toRunTargets, + useOpenTerminals, +} from "./TargetTerminals"; import { SnippetShareView, type ShareTarget } from "./SnippetShareView"; import { SnippetEditor, type SnippetFormValues } from "./SnippetEditor"; import { SnippetSettings } from "./SnippetSettings"; @@ -83,10 +88,22 @@ export function SnippetsPanel({ active: _active }: PanelProps) { const settings = useSettings("user"); const display = readSnippetSettings(settings.values); const { + runSnippet, runOnActive, runOnHosts, dialog: runnerDialog, } = useSnippetRunner(display.confirmExecution); + const openTerminals = useOpenTerminals(); + const [selectedTerminals, setSelectedTerminals] = useState>( + () => new Set(), + ); + useEffect(() => { + setSelectedTerminals((prev) => { + const open = new Set(openTerminals.map((terminal) => terminal.id)); + const next = new Set([...prev].filter((id) => open.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [openTerminals]); const { hosts } = useHosts(); const { confirmWithToast } = useConfirmation(); @@ -254,7 +271,9 @@ export function SnippetsPanel({ active: _active }: PanelProps) { function handleRun(snippet: Snippet) { const targets = targetHostsOf(snippet); - if (targets.length > 0) runOnHosts(snippet, targets); + const terminalTargets = toRunTargets(openTerminals, selectedTerminals); + if (targets.length > 0 && !snippet.isNote) runOnHosts(snippet, targets); + else if (terminalTargets.length > 0) runSnippet(snippet, terminalTargets); else runOnActive(snippet, null); } @@ -576,6 +595,12 @@ export function SnippetsPanel({ active: _active }: PanelProps) { )} + + ([]); + useEffect(() => { + let cancelled = false; + const load = () => { + void invokeAction("terminal.listSessions") + .then((result) => { + if (cancelled) return; + const next = Array.isArray(result) ? (result as OpenTerminal[]) : []; + setTerminals((prev) => + prev.length === next.length && + prev.every( + (item, i) => + item.id === next[i].id && item.label === next[i].label, + ) + ? prev + : next, + ); + }) + .catch(() => { + if (!cancelled) setTerminals([]); + }); + }; + load(); + const timer = setInterval(load, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, []); + return terminals; +} + +export function toRunTargets( + terminals: OpenTerminal[], + selected: Set, +): RunTarget[] { + return terminals + .filter((terminal) => selected.has(terminal.id)) + .map((terminal) => ({ + sessionId: terminal.id, + host: { + name: terminal.hostName, + ip: terminal.ip, + username: terminal.username, + port: terminal.port, + }, + })); +} + +/** Pick terminals to run in; with none picked a snippet runs in the active one. */ +export function TargetTerminals({ + terminals, + selected, + onChange, +}: { + terminals: OpenTerminal[]; + selected: Set; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + if (terminals.length < 2) return null; + + const toggle = (id: string) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange(next); + }; + + return ( +
+
+ + {t("targetTerminals")}{" "} + + ({t("targetTerminalsHint")}) + + +
+ + +
+
+
+ {terminals.map((terminal) => { + const isSelected = selected.has(terminal.id); + return ( + + ); + })} +
+
+ ); +} diff --git a/plugins/snippets/tests/frontend/snippets-panel.test.tsx b/plugins/snippets/tests/frontend/snippets-panel.test.tsx index 0854deaab..fad4c2b02 100644 --- a/plugins/snippets/tests/frontend/snippets-panel.test.tsx +++ b/plugins/snippets/tests/frontend/snippets-panel.test.tsx @@ -210,3 +210,37 @@ describe("snippets panel", () => { ); }); }); + +describe("snippet target terminals", () => { + it("runs a snippet in every picked terminal", async () => { + const sent: Array<[string, string]> = []; + rendered = await renderWithApp(plugin, { + manifest, + locales, + api: fakeApi([snippet({})]), + permissions: PERMISSIONS, + hosts: HOSTS as never, + }); + rendered.app.registerAction("terminal.listSessions", () => [ + { id: "s1", label: "web-1", hostName: "web-1" }, + { id: "s2", label: "web-2", hostName: "web-2" }, + ]); + rendered.app.registerAction("terminal.sendToSession", (( + id: string, + text: string, + ) => { + sent.push([id, text]); + return true; + }) as never); + rendered.renderPanel("snippets", { active: true }); + + fireEvent.click(await screen.findByText(locales.selectAll)); + fireEvent.click(screen.getAllByTitle(locales.run)[0]); + await waitFor(() => + expect(sent).toEqual([ + ["s1", "ls -la"], + ["s2", "ls -la"], + ]), + ); + }); +});