diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json index d1b22fae4..2180cee2b 100644 --- a/plugins/ssh-terminal/locales/en.json +++ b/plugins/ssh-terminal/locales/en.json @@ -220,5 +220,8 @@ "title": "Read command history", "description": "Lets other features, such as the AI assistant, read your command history." } + }, + "hosts": { + "tabTerminal": "Terminal" } } diff --git a/plugins/ssh-terminal/src/frontend/index.tsx b/plugins/ssh-terminal/src/frontend/index.tsx index a27fef511..3ece77c4e 100644 --- a/plugins/ssh-terminal/src/frontend/index.tsx +++ b/plugins/ssh-terminal/src/frontend/index.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, type ComponentType } from "react"; -import { History, Laptop, Terminal } from "lucide-react"; +import { History, Laptop, SquareTerminal, Terminal } from "lucide-react"; import type { PanelProps, StandaloneViewProps, @@ -21,6 +21,7 @@ import { TerminalView } from "./TerminalView"; import { HistoryPanel } from "./history/HistoryPanel"; import { TouchInputSettings } from "./settings/TouchInputSettings"; import { ImageStorageTest } from "./settings/ImageStorageTest"; +import { HostTerminalSection } from "./settings/HostTerminalSection"; import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store"; import { hostSetting } from "./terminal-api"; @@ -94,6 +95,15 @@ export function activate(app: TermixApp): void { !!host.enableSsh && hostSetting(host, "enableTerminal", true), }); + app.registerHostEditorSection({ + id: "terminal", + group: "ssh", + titleKey: "hosts.tabTerminal", + icon: SquareTerminal, + order: 10, + component: HostTerminalSection, + }); + if (isElectron()) { app.registerTab("local-terminal", LocalTerminalTab, { icon: Laptop, diff --git a/plugins/ssh-terminal/tests/frontend/activate.test.tsx b/plugins/ssh-terminal/tests/frontend/activate.test.tsx index 247aac198..099f75559 100644 --- a/plugins/ssh-terminal/tests/frontend/activate.test.tsx +++ b/plugins/ssh-terminal/tests/frontend/activate.test.tsx @@ -63,6 +63,11 @@ describe(`${manifest.id} activate`, () => { ); }); + it("owns the host editor's Terminal tab in the SSH group", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.hostEditorSections()).toEqual(["terminal"]); + }); + it("registers the command history panel and its rail item", async () => { rendered = await renderWithApp(plugin, { manifest, locales }); expect(rendered.registered.panels()).toContain("history"); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 9b440a1be..f44a08bc0 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -344,7 +344,6 @@ "uploadFile": "Upload File", "tabGeneral": "General", "tabSsh": "SSH", - "tabTerminal": "Terminal", "tabStats": "Host Metrics", "tabSharing": "Sharing", "tabAuthentication": "Authentication", diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 6bff121a7..35d9a9813 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -70,6 +70,12 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext"; // Slots: a plugin can offer places for other plugins to fill. export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot"; +// A plugin's manifest host settings, drawn inside its own host editor section. +export { + HostFeatureFields, + type HostFeatureFieldsProps, +} from "@/settings/HostPluginSections"; + // Components other plugins offer by id (app.registerComponent), rendered with // a fallback while their plugin is off. export { PluginComponent } from "@/plugin-host/component-registry"; @@ -103,6 +109,11 @@ export * from "@/lib/terminal-themes"; export { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme"; export { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles"; export * from "@/lib/terminal-look/terminal-font-zoom"; +export { TerminalPreview } from "@/components/terminal-preview/TerminalPreview"; +export { + HostTerminalSettings, + type HostTerminalSettingsProps, +} from "@/sidebar/HostTerminalSettings"; export { copyToClipboard, readFromClipboard } from "@/lib/clipboard"; export { RobustClipboardProvider } from "@/lib/clipboard-provider"; export { useTheme as useAppTheme } from "@/components/theme-provider"; diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index 9f9f90434..5fccdf42f 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { SectionCard } from "@/components/section-card"; import { PluginIcon } from "@/lib/plugin-icon"; import { getPlugins, type PluginSummary } from "@/api/plugins-api"; +import { usePluginScope } from "@/plugin-host/scope"; import { SettingsFieldRow } from "./SettingsFields"; import { hasVisibleFields, isFieldActive } from "./settings-fields-util"; @@ -112,6 +113,47 @@ export function HostPluginSections({ ); } +export interface HostFeatureFieldsProps { + // The host editor form, as handed to a host editor section. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: any; + updateForm: ( + patch: (form: Record) => Record, + ) => void; +} + +/** + * The calling plugin's manifest host settings, for a plugin that registered + * its own host editor section and so gets no generated tab. + */ +export function HostFeatureFields({ + form, + updateForm, +}: HostFeatureFieldsProps) { + const pluginId = usePluginScope(); + const plugin = usePluginHostSections().find((p) => p.id === pluginId); + if (!plugin) return null; + const values = (form?.pluginSettings as HostPluginSettings | undefined) ?? {}; + return ( + + updateForm((current) => { + const all = (current.pluginSettings ?? {}) as HostPluginSettings; + return { + ...current, + pluginSettings: { + ...all, + [plugin.id]: { ...(all[plugin.id] ?? {}), [key]: value }, + }, + }; + }) + } + /> + ); +} + function HostPluginSection({ plugin, values, diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 4265d2148..bddaaf2d4 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -1,42 +1,13 @@ import { Select2 } from "@/components/select2"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - TERMINAL_THEMES, - TERMINAL_FONTS, - BELL_STYLES, - FAST_SCROLL_MODIFIERS, - CURSOR_STYLES, -} from "@/lib/terminal-themes"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; -import { Slider } from "@/components/slider"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/tooltip"; -import { - TERMINAL_FONT_ZOOM_MIN, - TERMINAL_FONT_ZOOM_MAX, -} from "@/lib/terminal-look/terminal-font-zoom"; -import { - Globe, - Info, - Palette, - Pencil, - Plus, - Shield, - Upload, - X, - Zap, -} from "lucide-react"; +import { Globe, Pencil, Plus, Shield, Upload, X } from "lucide-react"; import { toast } from "sonner"; import { listSnippets } from "@/lib/snippet-provider"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; -import { TerminalPreview } from "@/components/terminal-preview/TerminalPreview"; import { createSSHHost, updateSSHHost, @@ -49,12 +20,6 @@ import { adminCreateUserCredential, } from "@/main-axios"; import { getHostDefaults } from "@/api/settings-api"; -import { - getUserPreferences, - saveUserPreferences, - parseCustomThemes, - type SavedCustomTheme, -} from "@/api/open-tabs-api"; import type { Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; import { useTabsSafe } from "@/shell/TabContext"; @@ -67,10 +32,6 @@ import { omitOwnerSshAuthFromSharedEdit, terminalAppearanceKeys, type HostAuthType, - type HostBellStyle, - type HostBackspaceMode, - type HostCursorStyle, - type HostFastScrollModifier, type HostProtocols, } from "./HostEditorData"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; @@ -97,8 +58,6 @@ import { type CredentialOption, } from "./quick-created-credential"; -const CUSTOM_FONT_OPTION = "__custom__"; - export function HostEditor({ host, draft, @@ -146,10 +105,6 @@ export function HostEditor({ host ? undefined : draft, ), ); - const [isCustomFont, setIsCustomFont] = useState( - () => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily), - ); - const setField = (k: K, v: (typeof form)[K]) => { onDirtyChange?.(true); setForm((p) => ({ @@ -184,62 +139,6 @@ export function HostEditor({ useState(false); const [quickCreatedCredential, setQuickCreatedCredential] = useState(null); - const [savedThemes, setSavedThemes] = useState([]); - const [savingTheme, setSavingTheme] = useState(false); - - const reloadSavedThemes = () => { - getUserPreferences() - .then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes))) - .catch(() => {}); - }; - - useEffect(() => { - reloadSavedThemes(); - }, []); - - const handleSaveAsGlobalTheme = async () => { - const colors = form.customThemeColors; - if (!colors) return; - const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt")); - if (!name || !name.trim()) return; - setSavingTheme(true); - try { - const newTheme: SavedCustomTheme = { - id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: name.trim(), - colors, - }; - const updated = [...savedThemes, newTheme]; - await saveUserPreferences({ - customThemes: JSON.stringify(updated), - }); - setSavedThemes(updated); - toast.success(t("hosts.saveGlobalThemeSuccess")); - } catch { - toast.error(t("hosts.saveGlobalThemeError")); - } finally { - setSavingTheme(false); - } - }; - - const handleDeleteGlobalTheme = async (id: string) => { - const updated = savedThemes.filter((theme) => theme.id !== id); - try { - await saveUserPreferences({ - customThemes: JSON.stringify(updated), - }); - setSavedThemes(updated); - } catch { - toast.error(t("hosts.saveGlobalThemeError")); - } - }; - - const handleApplyGlobalTheme = (id: string) => { - const theme = savedThemes.find((entry) => entry.id === id); - if (!theme) return; - setField("customThemeColors", { ...theme.colors }); - }; - useEffect(() => { getUserInfo() .then((info) => setIsOidcUser(info.is_oidc)) @@ -561,6 +460,34 @@ export function HostEditor({ className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> +
+
+ + + setField("keepaliveInterval", Number(e.target.value)) + } + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ + + setField("keepaliveCountMax", Number(e.target.value)) + } + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
)} - {activeTab === "terminal-options" && ( - <> - } - > -
- - - setField("inheritTerminalAppearance", value) - } - /> - -
-
- - -
-
-
- - { - const newTheme = e.target.value; - setField("theme", newTheme); - setPreviewTerminalTheme(newTheme); - if ( - newTheme === "custom" && - !form.customThemeColors - ) { - setField("customThemeColors", { - ...TERMINAL_THEMES.termixDark.colors, - }); - } - }} - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - {Object.entries(TERMINAL_THEMES) - .filter( - ([key]) => - key !== "termixDark" && key !== "termixLight", - ) - .map(([key, theme]) => ( - - ))} - -
-
-
- - - - - - - - {t("hosts.fontFamilyCustomHint")} - - - -
- { - if (e.target.value === CUSTOM_FONT_OPTION) { - setIsCustomFont(true); - setField("fontFamily", ""); - } else { - setIsCustomFont(false); - setField("fontFamily", e.target.value); - } - }} - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono" - > - {TERMINAL_FONTS.map((f) => ( - - ))} - - - {isCustomFont && ( - - setField("fontFamily", e.target.value) - } - placeholder={t("hosts.fontFamilyCustomPlaceholder")} - className="h-9 text-xs font-mono" - /> - )} -
-
-
- - - {form.fontSize}px - -
- setField("fontSize", v)} - /> -
-
- - - setField( - "cursorStyle", - e.target.value as HostCursorStyle, - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - {CURSOR_STYLES.map((s) => ( - - ))} - -
-
-
- - - {form.letterSpacing}px - -
- setField("letterSpacing", v)} - /> -
-
-
- - - {form.lineHeight.toFixed(1)} - -
- setField("lineHeight", v)} - /> -
-
- - - setField( - "bellStyle", - e.target.value as HostBellStyle, - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - {BELL_STYLES.map((b) => ( - - ))} - -
-
- - - setField( - "backspaceMode", - e.target.value as HostBackspaceMode, - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - - - -
-
-
- {form.theme === "custom" && ( -
-
-
- - -
- {savedThemes.length === 0 ? ( -

- {t("hosts.noSavedThemes")} -

- ) : ( -
- {savedThemes.map((theme) => ( -
- - -
- ))} -
- )} -
-
- - -
- {( - [ - ["background", "customThemeBackground"], - ["foreground", "customThemeForeground"], - ["cursor", "customThemeCursor"], - ["cursorAccent", "customThemeCursorAccent"], - ["selectionBackground", "customThemeSelection"], - ] as const - ).map(([key, labelKey]) => ( -
- -
- - setField("customThemeColors", { - ...(form.customThemeColors ?? - TERMINAL_THEMES.termixDark.colors), - [key]: e.target.value, - }) - } - className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5" - /> - - {form.customThemeColors?.[key] ?? - TERMINAL_THEMES.termixDark.colors[key]} - -
-
- ))} - -
- {( - [ - ["black", "customThemeBlack"], - ["brightBlack", "customThemeBrightBlack"], - ["red", "customThemeRed"], - ["brightRed", "customThemeBrightRed"], - ["green", "customThemeGreen"], - ["brightGreen", "customThemeBrightGreen"], - ["yellow", "customThemeYellow"], - ["brightYellow", "customThemeBrightYellow"], - ["blue", "customThemeBlue"], - ["brightBlue", "customThemeBrightBlue"], - ["magenta", "customThemeMagenta"], - ["brightMagenta", "customThemeBrightMagenta"], - ["cyan", "customThemeCyan"], - ["brightCyan", "customThemeBrightCyan"], - ["white", "customThemeWhite"], - ["brightWhite", "customThemeBrightWhite"], - ] as const - ).map(([key, labelKey]) => ( -
- -
- - setField("customThemeColors", { - ...(form.customThemeColors ?? - TERMINAL_THEMES.termixDark.colors), - [key]: e.target.value, - }) - } - className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5" - /> - - {form.customThemeColors?.[key] ?? - TERMINAL_THEMES.termixDark.colors[key]} - -
-
- ))} -
-
- )} - - setField("cursorBlink", v)} - /> - - - setField("rightClickSelectsWord", v)} - /> - - - setField("macOptionIsMeta", v)} - /> - - - setField("syntaxHighlighting", v)} - /> - - {form.syntaxHighlighting && ( -
- {( - [ - [ - "logLevels", - "syntaxCategoryLogLevels", - "syntaxCategoryLogLevelsDesc", - ], - [ - "paths", - "syntaxCategoryPaths", - "syntaxCategoryPathsDesc", - ], - [ - "timestamps", - "syntaxCategoryTimestamps", - "syntaxCategoryTimestampsDesc", - ], - [ - "ipAddresses", - "syntaxCategoryIpAddresses", - "syntaxCategoryIpAddressesDesc", - ], - [ - "urls", - "syntaxCategoryUrls", - "syntaxCategoryUrlsDesc", - ], - [ - "numbers", - "syntaxCategoryNumbers", - "syntaxCategoryNumbersDesc", - ], - ] as const - ).map(([key, labelKey, descKey]) => ( - - - setField("syntaxHighlightingOptions", { - ...form.syntaxHighlightingOptions, - [key]: v, - }) - } - /> - - ))} -
- )} -
- -

- {t("hosts.backgroundImageDesc")} -

- - setField("backgroundImage", e.target.value) - } - placeholder="https://example.com/image.jpg" - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono" - /> -
- {form.backgroundImage && ( -
-
- - - {Math.round(form.backgroundImageOpacity * 100)}% - -
- - setField("backgroundImageOpacity", v) - } - /> -
- )} -
-
- - } - > -
-
-
- - - {form.scrollback.toLocaleString()}{" "} - {t("hosts.scrollbackMaxLines")} - -
- setField("scrollback", v)} - /> -
- - {t("hosts.enableAutoTmuxDesc")}{" "} - - {t("hosts.docsLink")} - - - } - > - setField("autoTmux", v)} - /> - - - setField("agentForwarding", v)} - /> - - - setField("useSSHTitle", v)} - /> - - - setField("autoMosh", v)} - /> - -
- - - setField( - "localEcho", - e.target.value as "default" | "off" | "auto" | "on", - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - - - - - -

- {t("hosts.localEchoDesc")}{" "} - - {t("hosts.docsLink")} - -

-
-
- - - setField( - "linkClickBehavior", - e.target.value as "default" | "confirm" | "direct", - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - - - - -

- {t("hosts.linkClickBehaviorDesc")} -

-
-
-
- - {t("hosts.environmentVariablesLabel")} - - -
- {form.environmentVariables.length === 0 && ( -

- {t("hosts.noEnvVars")} -

- )} -
- {form.environmentVariables.map((ev, i) => ( -
- { - const updated = [...form.environmentVariables]; - updated[i] = { - ...updated[i], - key: e.target.value, - }; - setField("environmentVariables", updated); - }} - /> - { - const updated = [...form.environmentVariables]; - updated[i] = { - ...updated[i], - value: e.target.value, - }; - setField("environmentVariables", updated); - }} - /> - -
- ))} -
-
-
-
- - - setField( - "fastScrollModifier", - e.target.value as HostFastScrollModifier, - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - {FAST_SCROLL_MODIFIERS.map((m) => ( - - ))} - -
-
-
- - - {form.fastScrollSensitivity} - -
- - setField("fastScrollSensitivity", v) - } - /> -
-
- {form.autoMosh && ( -
- - - setField("moshCommand", e.target.value) - } - /> -
- )} -
-
- - - setField( - "startupSnippetId", - e.target.value ? Number(e.target.value) : null, - ) - } - className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" - > - - {snippets.map((s) => ( - - ))} - -
-
-
-
- - - setField("keepaliveInterval", Number(e.target.value)) - } - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
- - - setField("keepaliveCountMax", Number(e.target.value)) - } - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
-
-
- - )} - {(() => { const section = getHostEditorSection(activeTab); if (!section) return null; diff --git a/src/ui/sidebar/HostManagerTabs.tsx b/src/ui/sidebar/HostManagerTabs.tsx index e752c6ce5..208acb839 100644 --- a/src/ui/sidebar/HostManagerTabs.tsx +++ b/src/ui/sidebar/HostManagerTabs.tsx @@ -1,10 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ import { useEffect, useRef, type ComponentType, type ReactNode } from "react"; -import { KeyRound, Settings, SquareTerminal, Terminal } from "lucide-react"; +import { KeyRound, Settings, Terminal } from "lucide-react"; import { byOrderThenId, createRegistry } from "@/lib/registry"; /** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */ -export type CoreHostTabId = "general" | "ssh" | "terminal-options"; +export type CoreHostTabId = "general" | "ssh"; export type HostTabId = CoreHostTabId | (string & {}); export type CredentialTabId = "general" | "auth"; @@ -62,7 +62,7 @@ export const hostEditorSectionList = sections.list; export const useHostEditorSections = sections.useList; export const resetHostEditorSections = sections.reset; -const CORE_SSH_GROUP = new Set(["ssh", "terminal-options"]); +const CORE_SSH_GROUP = new Set(["ssh"]); /** Whether a tab lives in the SSH group's second strip. */ export function isSshGroupTab(id: string): boolean { @@ -149,12 +149,6 @@ export function makeHostSshSubTabs( icon: , order: 0, }, - { - id: "terminal-options", - label: t("hosts.tabTerminal"), - icon: , - order: 10, - }, ], "ssh", t, diff --git a/src/ui/tests/plugin-host/registries.test.tsx b/src/ui/tests/plugin-host/registries.test.tsx index fecd5849f..c11a7131b 100644 --- a/src/ui/tests/plugin-host/registries.test.tsx +++ b/src/ui/tests/plugin-host/registries.test.tsx @@ -244,11 +244,7 @@ describe("registries through the app object", () => { it("adds a host editor section to the SSH group in order", async () => { await mount(); const ids = makeHostSshSubTabs((key) => key).map((tab) => tab.id); - expect(ids.slice(0, 3)).toEqual([ - "ssh", - "terminal-options", - "fixture-section", - ]); + expect(ids.slice(0, 2)).toEqual(["ssh", "fixture-section"]); expect(makeHostTabs((key) => key).map((tab) => tab.id)).not.toContain( "fixture-section", ); diff --git a/src/ui/tests/settings/host-feature-tabs.test.tsx b/src/ui/tests/settings/host-feature-tabs.test.tsx index 746c5e3a9..694664fa7 100644 --- a/src/ui/tests/settings/host-feature-tabs.test.tsx +++ b/src/ui/tests/settings/host-feature-tabs.test.tsx @@ -3,7 +3,13 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import type { PluginSummary } from "@/api/plugins-api"; import { hostFeatureTabId, @@ -14,7 +20,16 @@ import { registerHostEditorSection, resetHostEditorSections, } from "@/sidebar/HostManagerTabs"; -import { HostPluginSections } from "@/settings/HostPluginSections"; +import { + HostFeatureFields, + HostPluginSections, +} from "@/settings/HostPluginSections"; +import { withPluginScope } from "@/plugin-host/scope"; + +const getPlugins = vi.fn(); +vi.mock("@/api/plugins-api", () => ({ + getPlugins: () => getPlugins(), +})); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), @@ -266,3 +281,43 @@ describe("HostPluginSections", () => { expect(screen.getByDisplayValue("web")).toBeTruthy(); }); }); + +describe("HostFeatureFields", () => { + it("draws the calling plugin's fields and writes into its pluginSettings", async () => { + getPlugins.mockResolvedValue([ + plugin(), + plugin({ id: "warpgate", name: "Warpgate" }), + ]); + const form = { + pluginSettings: { + docker: { enableDocker: false }, + warpgate: { enableDocker: true }, + }, + }; + const updateForm = vi.fn(); + const Scoped = withPluginScope("docker", HostFeatureFields); + render(); + + await waitFor(() => + expect(screen.getByText("docker:enableDocker.label")).toBeTruthy(), + ); + expect(screen.getAllByText("Docker")).toHaveLength(1); + expect(screen.queryByText("Warpgate")).toBeNull(); + + fireEvent.click(screen.getAllByRole("button")[0]); + const patch = updateForm.mock.calls[0][0]; + expect(patch(form).pluginSettings).toEqual({ + docker: { enableDocker: true }, + warpgate: { enableDocker: true }, + }); + }); + + it("renders nothing outside a plugin", async () => { + getPlugins.mockResolvedValue([plugin()]); + const { container } = render( + , + ); + await waitFor(() => expect(getPlugins).toHaveBeenCalled()); + expect(container.innerHTML).toBe(""); + }); +});