diff --git a/src/ui/components/select2.tsx b/src/ui/components/select2.tsx index e9405242d..991a1d471 100644 --- a/src/ui/components/select2.tsx +++ b/src/ui/components/select2.tsx @@ -154,7 +154,7 @@ function Select2({ aria-expanded={open} aria-required={required} className={cn( - "border-input bg-background text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", + "border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className, )} onKeyDown={(event) => { diff --git a/src/ui/hooks/use-installed-plugins.ts b/src/ui/hooks/use-installed-plugins.ts new file mode 100644 index 000000000..e623357c4 --- /dev/null +++ b/src/ui/hooks/use-installed-plugins.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { + getPlugins, + PLUGINS_CHANGED_EVENT, + type PluginSummary, +} from "@/api/plugins-api"; + +/** + * Every installed plugin, read again whenever one is enabled, disabled, + * installed or removed, so what a plugin adds shows up without a reload. + */ +export function useInstalledPlugins(): PluginSummary[] { + const [plugins, setPlugins] = useState([]); + + useEffect(() => { + let cancelled = false; + let request = 0; + const load = () => { + const current = ++request; + void getPlugins() + .then((loaded) => { + if (!cancelled && current === request) setPlugins(loaded); + }) + .catch(() => {}); + }; + load(); + window.addEventListener(PLUGINS_CHANGED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(PLUGINS_CHANGED_EVENT, load); + }; + }, []); + + return plugins; +} diff --git a/src/ui/i18n/i18n.ts b/src/ui/i18n/i18n.ts index c26a1845e..8921b3642 100644 --- a/src/ui/i18n/i18n.ts +++ b/src/ui/i18n/i18n.ts @@ -168,6 +168,8 @@ i18n react: { useSuspense: false, + // Repaint when a plugin enabled later loads its strings. + bindI18n: "languageChanged loaded", }, }); diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 978b8bb07..adc3e6c97 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -23,6 +23,7 @@ import { getPluginAdminSettings, getPluginHostSettings, getPluginUserSettings, + PLUGIN_SETTINGS_CHANGED_EVENT, updatePluginAdminSettings, updatePluginHostSettings, updatePluginUserSettings, @@ -122,6 +123,12 @@ function loadCurrentUser() { return currentUser; } +interface SettingsChangeDetail { + pluginId: string; + scope: SettingsScope; + hostId?: number; +} + const SETTINGS_READERS: Record< SettingsScope, (pluginId: string, hostId?: number) => Promise> @@ -245,15 +252,26 @@ export const pluginHostBridge: PluginHostBridge = { useEffect(() => { let cancelled = false; if (scope === "host" && !Number.isFinite(numericHostId)) return; - SETTINGS_READERS[scope](pluginId, numericHostId) - .then((values) => { - if (!cancelled) setState({ values, loaded: true }); - }) - .catch(() => { - if (!cancelled) setState((prev) => ({ ...prev, loaded: true })); - }); + const read = () => + SETTINGS_READERS[scope](pluginId, numericHostId) + .then((values) => { + if (!cancelled) setState({ values, loaded: true }); + }) + .catch(() => { + if (!cancelled) setState((prev) => ({ ...prev, loaded: true })); + }); + void read(); + // Saved from the settings page, which keeps its own copy of the form. + const onChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.pluginId !== pluginId || detail.scope !== scope) return; + if (scope === "host" && detail.hostId !== numericHostId) return; + void read(); + }; + window.addEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged); return () => { cancelled = true; + window.removeEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged); }; }, [pluginId, scope, numericHostId]); diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 033f54c41..8f33ff7b9 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -489,7 +489,8 @@ function InstalledCard({ ) : !running ? ( -
+
+ {t("plugins.manager.status.disabled")} diff --git a/src/ui/settings/FeatureSettingsSections.tsx b/src/ui/settings/FeatureSettingsSections.tsx index b08b4ea8e..b1047924a 100644 --- a/src/ui/settings/FeatureSettingsSections.tsx +++ b/src/ui/settings/FeatureSettingsSections.tsx @@ -14,7 +14,6 @@ import { AccordionSection } from "@/sidebar/AdminSettingsShared"; import { pluginKey } from "@/lib/plugin-i18n"; import { PluginIcon } from "@/lib/plugin-icon"; import { - getPlugins, getPluginAdminSettings, getPluginUserSettings, updatePluginAdminSettings, @@ -24,6 +23,7 @@ import { type PluginSettingsField, type PluginSummary, } from "@/api/plugins-api"; +import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; import { SettingsFieldRow } from "./SettingsFields"; import { hasVisibleFields, isFieldShown } from "./settings-fields-util"; @@ -39,19 +39,7 @@ export function featureSectionId(pluginId: string): FeatureSectionId { export function useFeatureSettings( scope: FeatureSettingsScope, ): PluginSummary[] { - const [plugins, setPlugins] = useState([]); - - useEffect(() => { - let cancelled = false; - void getPlugins() - .then((loaded) => { - if (!cancelled) setPlugins(loaded); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); + const plugins = useInstalledPlugins(); return useMemo( () => diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index a1c0d77f9..29cc9ce52 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -10,11 +10,12 @@ * group does not appear as an empty tab. */ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; 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 { type PluginSummary } from "@/api/plugins-api"; +import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; import { usePluginScope } from "@/plugin-host/scope"; import { SettingsFieldRow } from "./SettingsFields"; import { hasVisibleFields, isFieldShown } from "./settings-fields-util"; @@ -24,21 +25,7 @@ export type HostPluginSettings = Record>; /** Enabled plugins declaring host-scope settings. */ export function usePluginHostSections(): PluginSummary[] { - const [plugins, setPlugins] = useState([]); - - useEffect(() => { - let cancelled = false; - void getPlugins() - .then((loaded) => { - if (!cancelled) setPlugins(loaded); - }) - .catch(() => { - // Additive: the built-in host tabs do not depend on this. - }); - return () => { - cancelled = true; - }; - }, []); + const plugins = useInstalledPlugins(); return useMemo( () => diff --git a/src/ui/sidebar/host-defaults/catalog.ts b/src/ui/sidebar/host-defaults/catalog.ts index 9730660b3..b4574f811 100644 --- a/src/ui/sidebar/host-defaults/catalog.ts +++ b/src/ui/sidebar/host-defaults/catalog.ts @@ -3,7 +3,7 @@ * running plugin's host fields that can have one. */ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { CORE_HOST_DEFAULTS, CORE_NAMESPACE, @@ -11,10 +11,10 @@ import { type HostDefaultsLevel, } from "@/types/host-defaults"; import { - getPlugins, type PluginSettingsField, type PluginSummary, } from "@/api/plugins-api"; +import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; export interface EditorDefaultKey { fullKey: string; @@ -71,18 +71,7 @@ export const CORE_EDITOR_KEYS: EditorDefaultKey[] = CORE_HOST_DEFAULTS.map( /** Every key, by full key. Refreshed when the plugin list is. */ export function useEditorDefaultKeys(): Map { - const [plugins, setPlugins] = useState([]); - useEffect(() => { - let cancelled = false; - void getPlugins() - .then((loaded) => { - if (!cancelled) setPlugins(loaded); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); + const plugins = useInstalledPlugins(); return useMemo( () => new Map( diff --git a/src/ui/tests/plugin-host/bridge.test.ts b/src/ui/tests/plugin-host/bridge.test.ts index d2dbf8b9d..c533df669 100644 --- a/src/ui/tests/plugin-host/bridge.test.ts +++ b/src/ui/tests/plugin-host/bridge.test.ts @@ -1,5 +1,48 @@ -import { describe, expect, it } from "vitest"; -import { toHostRecord } from "@/plugin-host/bridge"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; + +const getPluginUserSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/plugins-api", async (importOriginal) => ({ + ...(await importOriginal()), + getPluginUserSettings, +})); + +import { pluginHostBridge, toHostRecord } from "@/plugin-host/bridge"; +import { PLUGIN_SETTINGS_CHANGED_EVENT } from "@/api/plugins-api"; + +afterEach(cleanup); + +function announce(pluginId: string, scope: string) { + act(() => { + window.dispatchEvent( + new CustomEvent(PLUGIN_SETTINGS_CHANGED_EVENT, { + detail: { pluginId, scope }, + }), + ); + }); +} + +describe("useSettings", () => { + it("reads again when the settings page saves the same plugin and scope", async () => { + getPluginUserSettings.mockResolvedValueOnce({ enabled: false }); + const { result } = renderHook(() => + pluginHostBridge.useSettings("ai", "user"), + ); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.values).toEqual({ enabled: false }); + + getPluginUserSettings.mockResolvedValue({ enabled: true }); + announce("other", "user"); + announce("ai", "admin"); + expect(getPluginUserSettings).toHaveBeenCalledTimes(1); + + announce("ai", "user"); + await waitFor(() => + expect(result.current.values).toEqual({ enabled: true }), + ); + }); +}); describe("toHostRecord", () => { it("copies only the fields the SDK types", () => { diff --git a/src/ui/tests/settings/FeatureSettingsSections.test.tsx b/src/ui/tests/settings/FeatureSettingsSections.test.tsx index 3030f7f11..cf361ffe4 100644 --- a/src/ui/tests/settings/FeatureSettingsSections.test.tsx +++ b/src/ui/tests/settings/FeatureSettingsSections.test.tsx @@ -106,6 +106,22 @@ describe("useFeatureSettings", () => { await waitFor(() => expect(api.getPlugins).toHaveBeenCalled()); expect(result.current).toEqual([]); }); + + it("picks up a feature enabled while it is open", async () => { + api.getPlugins.mockResolvedValue([ + plugin({ id: "late", name: "Late", enabled: false }), + ]); + const { result } = renderHook(() => useFeatureSettings("user")); + await waitFor(() => expect(api.getPlugins).toHaveBeenCalledTimes(1)); + expect(result.current).toEqual([]); + + api.getPlugins.mockResolvedValue([plugin({ id: "late", name: "Late" })]); + window.dispatchEvent(new CustomEvent("termix:plugins-changed")); + + await waitFor(() => + expect(result.current.map((p) => p.id)).toEqual(["late"]), + ); + }); }); describe("FeatureSettingsSection", () => { diff --git a/src/ui/tests/settings/host-feature-tabs.test.tsx b/src/ui/tests/settings/host-feature-tabs.test.tsx index 0ee362a27..cb44b9b67 100644 --- a/src/ui/tests/settings/host-feature-tabs.test.tsx +++ b/src/ui/tests/settings/host-feature-tabs.test.tsx @@ -28,6 +28,7 @@ import { withPluginScope } from "@/plugin-host/scope"; const getPlugins = vi.fn(); vi.mock("@/api/plugins-api", () => ({ + PLUGINS_CHANGED_EVENT: "termix:plugins-changed", getPlugins: () => getPlugins(), }));