mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +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-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) => {
|
||||||
|
|||||||
@@ -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: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
|
// Repaint when a plugin enabled later loads its strings.
|
||||||
|
bindI18n: "languageChanged loaded",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -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,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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user