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-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) => {
+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: {
useSuspense: false,
// Repaint when a plugin enabled later loads its strings.
bindI18n: "languageChanged loaded",
},
});
+25 -7
View File
@@ -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<Record<string, unknown>>
@@ -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<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 () => {
cancelled = true;
window.removeEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged);
};
}, [pluginId, scope, numericHostId]);
+2 -1
View File
@@ -489,7 +489,8 @@ function InstalledCard({
</span>
</div>
) : !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">
{t("plugins.manager.status.disabled")}
</span>
+2 -14
View File
@@ -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<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const plugins = useInstalledPlugins();
return useMemo(
() =>
+4 -17
View File
@@ -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<string, Record<string, unknown>>;
/** Enabled plugins declaring host-scope settings. */
export function usePluginHostSections(): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
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(
() =>
+3 -14
View File
@@ -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<string, EditorDefaultKey> {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const plugins = useInstalledPlugins();
return useMemo(
() =>
new Map(
+45 -2
View File
@@ -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<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", () => {
it("copies only the fields the SDK types", () => {
@@ -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", () => {
@@ -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(),
}));