mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-08 21:03:46 +00:00
fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling
This commit is contained in:
1 parent
27819c477c
commit
8666533162
11 files changed
+136
-56
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -168,6 +168,8 @@ i18n
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
// Repaint when a plugin enabled later loads its strings.
|
||||
bindI18n: "languageChanged loaded",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user