fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

This commit is contained in:
LukeGus committed 2026-10-06 23:08:21 -05:00
1 parent 27819c477c
commit 8666533162
11 files changed
+136 -56

No files matched your search

+1 -1
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open} aria-expanded={open}
aria-required={required} aria-required={required}
className={cn( 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, className,
)} )}
onKeyDown={(event) => { onKeyDown={(event) => {
+35
View File
@@ -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<PluginSummary[]>([]);
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;
}
+2
View File
@@ -168,6 +168,8 @@ i18n
react: { react: {
useSuspense: false, useSuspense: false,
// Repaint when a plugin enabled later loads its strings.
bindI18n: "languageChanged loaded",
}, },
}); });
+25 -7
View File
@@ -23,6 +23,7 @@ import {
getPluginAdminSettings, getPluginAdminSettings,
getPluginHostSettings, getPluginHostSettings,
getPluginUserSettings, getPluginUserSettings,
PLUGIN_SETTINGS_CHANGED_EVENT,
updatePluginAdminSettings, updatePluginAdminSettings,
updatePluginHostSettings, updatePluginHostSettings,
updatePluginUserSettings, updatePluginUserSettings,
@@ -122,6 +123,12 @@ function loadCurrentUser() {
return currentUser; return currentUser;
} }
interface SettingsChangeDetail {
pluginId: string;
scope: SettingsScope;
hostId?: number;
}
const SETTINGS_READERS: Record< const SETTINGS_READERS: Record<
SettingsScope, SettingsScope,
(pluginId: string, hostId?: number) => Promise<Record<string, unknown>> (pluginId: string, hostId?: number) => Promise<Record<string, unknown>>
@@ -245,15 +252,26 @@ export const pluginHostBridge: PluginHostBridge = {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (scope === "host" && !Number.isFinite(numericHostId)) return; if (scope === "host" && !Number.isFinite(numericHostId)) return;
SETTINGS_READERS[scope](pluginId, numericHostId) const read = () =>
.then((values) => { SETTINGS_READERS[scope](pluginId, numericHostId)
if (!cancelled) setState({ values, loaded: true }); .then((values) => {
}) if (!cancelled) setState({ values, loaded: true });
.catch(() => { })
if (!cancelled) setState((prev) => ({ ...prev, 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<SettingsChangeDetail>).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 () => { return () => {
cancelled = true; cancelled = true;
window.removeEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged);
}; };
}, [pluginId, scope, numericHostId]); }, [pluginId, scope, numericHostId]);
+2 -1
View File
@@ -489,7 +489,8 @@ function InstalledCard({
</span> </span>
</div> </div>
) : !running ? ( ) : !running ? (
<div className="border-t border-border bg-muted/20 px-3 py-2"> <div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{t("plugins.manager.status.disabled")} {t("plugins.manager.status.disabled")}
</span> </span>
+2 -14
View File
@@ -14,7 +14,6 @@ import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey } from "@/lib/plugin-i18n"; import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { import {
getPlugins,
getPluginAdminSettings, getPluginAdminSettings,
getPluginUserSettings, getPluginUserSettings,
updatePluginAdminSettings, updatePluginAdminSettings,
@@ -24,6 +23,7 @@ import {
type PluginSettingsField, type PluginSettingsField,
type PluginSummary, type PluginSummary,
} from "@/api/plugins-api"; } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { SettingsFieldRow } from "./SettingsFields"; import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util"; import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
@@ -39,19 +39,7 @@ export function featureSectionId(pluginId: string): FeatureSectionId {
export function useFeatureSettings( export function useFeatureSettings(
scope: FeatureSettingsScope, scope: FeatureSettingsScope,
): PluginSummary[] { ): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]); const plugins = useInstalledPlugins();
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return useMemo( return useMemo(
() => () =>
+4 -17
View File
@@ -10,11 +10,12 @@
* group does not appear as an empty tab. * group does not appear as an empty tab.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { PluginIcon } from "@/lib/plugin-icon"; 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 { usePluginScope } from "@/plugin-host/scope";
import { SettingsFieldRow } from "./SettingsFields"; import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util"; import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
@@ -24,21 +25,7 @@ export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins declaring host-scope settings. */ /** Enabled plugins declaring host-scope settings. */
export function usePluginHostSections(): PluginSummary[] { export function usePluginHostSections(): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]); const plugins = useInstalledPlugins();
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;
};
}, []);
return useMemo( return useMemo(
() => () =>
+3 -14
View File
@@ -3,7 +3,7 @@
* running plugin's host fields that can have one. * running plugin's host fields that can have one.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { import {
CORE_HOST_DEFAULTS, CORE_HOST_DEFAULTS,
CORE_NAMESPACE, CORE_NAMESPACE,
@@ -11,10 +11,10 @@ import {
type HostDefaultsLevel, type HostDefaultsLevel,
} from "@/types/host-defaults"; } from "@/types/host-defaults";
import { import {
getPlugins,
type PluginSettingsField, type PluginSettingsField,
type PluginSummary, type PluginSummary,
} from "@/api/plugins-api"; } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
export interface EditorDefaultKey { export interface EditorDefaultKey {
fullKey: string; 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. */ /** Every key, by full key. Refreshed when the plugin list is. */
export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> { export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> {
const [plugins, setPlugins] = useState<PluginSummary[]>([]); const plugins = useInstalledPlugins();
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return useMemo( return useMemo(
() => () =>
new Map( new Map(
+45 -2
View File
@@ -1,5 +1,48 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { toHostRecord } from "@/plugin-host/bridge"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
const getPluginUserSettings = vi.hoisted(() => vi.fn());
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
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", () => { describe("toHostRecord", () => {
it("copies only the fields the SDK types", () => { it("copies only the fields the SDK types", () => {
@@ -106,6 +106,22 @@ describe("useFeatureSettings", () => {
await waitFor(() => expect(api.getPlugins).toHaveBeenCalled()); await waitFor(() => expect(api.getPlugins).toHaveBeenCalled());
expect(result.current).toEqual([]); 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", () => { describe("FeatureSettingsSection", () => {
@@ -28,6 +28,7 @@ import { withPluginScope } from "@/plugin-host/scope";
const getPlugins = vi.fn(); const getPlugins = vi.fn();
vi.mock("@/api/plugins-api", () => ({ vi.mock("@/api/plugins-api", () => ({
PLUGINS_CHANGED_EVENT: "termix:plugins-changed",
getPlugins: () => getPlugins(), getPlugins: () => getPlugins(),
})); }));