Files
openidea/src/features/settings/components/context.ts
T
Arnaud Ritti 905a672bad feat(plugins): add declarative multi-page settings (#2074)
Closes #1720

Plugins can declare searchable multi-page settings (API 1.3): pages under the plugin in Settings, groups, string lists, editable tables and command buttons, while single-schema plugins keep working. A command button applies pending edits first and runs against the selected Global/Project layer (also remotely); Reset keeps other pages' edits, and invalid table/list edits never replace saved rows. Field and page order come from the schema without changing serde_json's map ordering elsewhere in the app.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 08:38:42 +08:00

81 lines
3.0 KiB
TypeScript

/** Page context for the form components: which page / scope they edit and the search query. */
import { createContext, useCallback, useContext, useMemo } from "react";
import type { SettingsForm } from "@/workbench/contributions";
import {
applySettings,
externalKey,
stage,
stageMapEntry,
stageExternal,
stagedValue,
useSettingsDialog,
type Scope,
} from "../model/staging";
import { labelMatches } from "../model/pages";
export interface PageContextValue {
pageId: string;
scope: Scope;
}
export const PageContext = createContext<PageContextValue>({ pageId: "", scope: "global" });
export function usePageContext(): PageContextValue {
return useContext(PageContext);
}
/** Staged value of a settings key plus a setter that stages edits for this page's scope. */
export function useSetting<T>(key: string, scopeOverride?: Scope): [T, (value: T) => void] {
const { pageId, scope: pageScope } = usePageContext();
const scope = scopeOverride ?? pageScope;
const value = useStagedValue(scope, key) as T;
const set = useCallback((v: T) => stage(pageId, scope, key, v), [pageId, scope, key]);
return [value, set];
}
/** `stagedValue` for components; recomputed only when the base or the staged edits change. */
export function useStagedValue(scope: Scope, key: string): unknown {
const base = useSettingsDialog((s) => (scope === "global" ? s.globalBase : s.base));
const staged = useSettingsDialog((s) => s.staged);
return useMemo(() => stagedValue({ base, globalBase: base, staged }, scope, key), [base, staged, scope, key]);
}
/** The form contributed pages edit through (`useSettingsForm`). */
export function useSettingsFormFor(pageId: string, scope: Scope): SettingsForm {
return useMemo(
() => ({
scope,
apply: () => applySettings(),
useValue: (key: string) => useStagedValue(scope, key),
set: (key: string, value: unknown) => stage(pageId, scope, key, value),
setMapEntry: (key: string, path: string[], value: unknown) => stageMapEntry(pageId, scope, key, path, value),
useExternal: <T>(key: string, base: T, apply: (value: T) => Promise<T>) =>
useExternalValue(pageId, key, base, apply),
}),
[pageId, scope],
);
}
function useExternalValue<T>(page: string, key: string, base: T, apply: (value: T) => Promise<T>) {
const id = externalKey(page, key);
const staged = useSettingsDialog((s) => s.stagedExternal[id]);
const current = useSettingsDialog((s) => (id in s.externalApplied ? (s.externalApplied[id] as T) : base));
const saving = useSettingsDialog((s) => s.saving);
return {
value: staged ? (staged.value as T) : current,
current,
set: (value: T) => stageExternal(page, key, value, current, apply),
saving,
};
}
export function useSearchQuery(): string {
return useSettingsDialog((s) => s.query);
}
/** Whether `label` should be highlighted for the current search. */
export function useLabelMatch(label: string | undefined): boolean {
const q = useSearchQuery();
return !!label && labelMatches(label, q);
}