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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 08:38:42 +08:00
1 джерело 99102d8a40
коміт 905a672bad
41 files changed
+1887 -161

No files matched your search

+86
Переглянути файл
@@ -0,0 +1,86 @@
.page,
.group,
.field,
.array {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.field {
gap: 4px;
}
.group h3 {
margin: 0;
font-size: inherit;
}
.description {
color: var(--fg-muted);
margin: 0;
}
.scroll {
overflow-x: auto;
}
.scroll table {
width: 100%;
border-collapse: collapse;
}
.scroll th,
.scroll td {
padding: 6px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.scroll input {
min-width: 100px;
}
.buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.error {
color: var(--danger);
}
.multiline {
min-height: 100px;
padding: var(--space-2);
color: var(--fg);
background: var(--bg-input);
border: 1px solid var(--border);
font: inherit;
resize: vertical;
}
.add {
align-self: flex-start;
}
.scroll th {
white-space: nowrap;
}
.scroll th:last-child,
.scroll td:last-child {
position: sticky;
right: 0;
background: var(--bg-dialog, var(--bg-elev));
}
.scroll td:last-child .buttons {
flex-wrap: nowrap;
}
.scalars {
max-width: 560px;
}
-13
Переглянути файл
@@ -351,19 +351,6 @@
margin-bottom: 12px;
}
.settingsPage {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 560px;
}
.settingRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.marketItem {
list-style: none;
padding: 4px 8px;
+280
Переглянути файл
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ComponentType } from "react";
import { SettingsFormContext, useSettingsPages } from "@/workbench/contributions";
import { useSettingsFormFor } from "@/features/settings/components/context";
import {
closeSettings,
openSettings,
resetPage,
stagedValue,
useSettingsDialog,
} from "@/features/settings/model/staging";
import { useProject } from "@/platform/project";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { projectSettings, type SettingsDecl } from "../settings";
const profile = {
key: "profiles",
title: "Model profiles",
type: "array",
group: "Provider",
default: [],
items: {
key: "item",
title: "item",
type: "object",
default: { name: "Local", enabled: true, limit: 5 },
properties: [
{ key: "name", title: "Name", type: "string", default: "Local" },
{ key: "enabled", title: "Enabled", type: "boolean", default: true },
{ key: "limit", title: "Token limit", type: "integer", default: 5 },
],
},
} as const;
const declaration: SettingsDecl = {
title: "Helper",
properties: [],
pages: [
{
id: "templates",
title: "Commit Template",
properties: [
{
key: "types",
title: "Allowed types",
type: "array",
default: ["feat", "fix"],
items: { key: "item", title: "item", type: "string", default: "" },
},
],
},
{
id: "llm",
title: "LLM Settings",
properties: [JSON.parse(JSON.stringify(profile))],
actions: [{ title: "Test", command: "test" }],
},
],
};
let dispose: () => void;
const run = vi.fn(async () => null);
beforeEach(() => {
useProject.setState({ settings: null, project: null });
openSettings();
run.mockReset().mockResolvedValue(null);
setPluginsBackend({ ...createMemoryPluginsBackend([]), runCommand: run });
dispose = projectSettings("example.helper", declaration);
});
afterEach(() => {
dispose();
closeSettings();
setPluginsBackend(null);
});
function Harness({
page,
apply,
scope = "global",
}: {
page: string;
apply?: () => Promise<boolean>;
scope?: "global" | "project";
}) {
const id = `plugins.example-helper.settings:${page}`;
const form = useSettingsFormFor(id, scope);
const Component = useSettingsPages.getState().pages.get(id)!.component as ComponentType;
return (
<SettingsFormContext.Provider value={apply ? { ...form, apply } : form}>
<Component />
</SettingsFormContext.Provider>
);
}
it("registers searchable child pages and removes all of them on unload", () => {
const pages = useSettingsPages.getState().pages;
expect(pages.get("plugins.example-helper")!.component).toBeUndefined();
const page = pages.get("plugins.example-helper.settings:llm")!;
expect(page.title).toBe("LLM Settings");
expect(page.keywords).toEqual(expect.arrayContaining(["Provider", "Name", "Token limit", "Test"]));
dispose();
expect([...useSettingsPages.getState().pages.keys()].some((k) => k.startsWith("plugins.example-helper"))).toBe(false);
});
it("stages table edits, keeps changes between pages and discards them on Cancel", () => {
const view = render(<Harness page="llm" />);
expect(screen.getByRole("heading", { name: "Provider" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.change(screen.getByRole("textbox", { name: "Model profiles: Name 1" }), { target: { value: "Cloud" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Model profiles: Enabled 1" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "Model profiles: Token limit 1" }), {
target: { value: "7.9" },
});
expect(useProject.getState().settings).toBeNull();
view.rerender(<Harness page="templates" />);
fireEvent.click(screen.getByRole("button", { name: "Add" }));
view.rerender(<Harness page="llm" />);
expect(screen.getByRole("textbox", { name: "Model profiles: Name 1" })).toHaveValue("Cloud");
const pending = stagedValue(useSettingsDialog.getState(), "global", "plugins");
expect(pending).toMatchObject({
"example.helper": { profiles: [{ name: "Cloud", enabled: false, limit: 7 }], types: ["feat", "fix", ""] },
});
act(() => {
closeSettings();
openSettings();
});
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
it("adds, edits, reorders and removes list entries", () => {
render(<Harness page="templates" />);
const table = within(screen.getByRole("table", { name: "Allowed types" }));
fireEvent.click(table.getAllByRole("button", { name: "Move Down" })[0]!);
expect(table.getAllByRole("textbox")[0]).toHaveValue("fix");
fireEvent.change(table.getAllByRole("textbox")[0]!, { target: { value: "docs" } });
fireEvent.click(table.getAllByRole("button", { name: "Move Up" })[1]!);
expect(table.getAllByRole("textbox")[0]).toHaveValue("feat");
fireEvent.click(table.getAllByRole("button", { name: "Remove" })[0]!);
expect(table.getAllByRole("textbox")).toHaveLength(1);
expect(table.getByRole("textbox")).toHaveValue("docs");
});
it("applies pending values before a command reads them and shows command errors inline", async () => {
run.mockImplementation(async () => {
expect(useProject.getState().settings).toMatchObject({
plugins: { "example.helper": { profiles: [{ name: "Local" }] } },
});
throw new Error("Connection refused");
});
render(<Harness page="llm" />);
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.click(screen.getByRole("button", { name: "Test" }));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Connection refused"));
expect(run).toHaveBeenCalledWith("example.helper", "test", null, null, [], "global");
expect(useSettingsDialog.getState().staged).toEqual({});
act(() => closeSettings());
expect(useProject.getState().settings).toMatchObject({
plugins: { "example.helper": { profiles: [{ name: "Local" }] } },
});
});
it("does not run a command when applying settings fails", async () => {
render(<Harness page="llm" apply={async () => false} />);
fireEvent.click(screen.getByRole("button", { name: "Test" }));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Could not apply settings."));
expect(run).not.toHaveBeenCalled();
});
it("preserves the legacy single-schema page", () => {
const remove = projectSettings("legacy", {
title: "Legacy",
properties: [{ key: "enabled", title: "Enabled", type: "boolean", default: true }],
});
const Component = useSettingsPages.getState().pages.get("plugins.legacy")!.component!;
function Legacy() {
const form = useSettingsFormFor("plugins.legacy", "global");
return (
<SettingsFormContext.Provider value={form}>
<Component />
</SettingsFormContext.Provider>
);
}
render(<Legacy />);
expect(screen.getByRole("checkbox", { name: "Enabled" })).toBeChecked();
remove();
});
it("loads saved plugin settings into the dialog, including dotted plugin ids and keys", () => {
useProject.setState({
settings: {
plugins: { "example.helper": { profiles: [{ name: "Saved", enabled: true, limit: 3 }], "custom.key": ["x"] } },
} as never,
});
openSettings();
render(<Harness page="llm" />);
expect(screen.getByRole("textbox", { name: "Model profiles: Name 1" })).toHaveValue("Saved");
});
it.each([{ profiles: [null] }, { profiles: [{}] }, { profiles: [1] }, { profiles: "invalid" }])(
"falls back from malformed stored tables: %j",
({ profiles }) => {
useProject.setState({ settings: { plugins: { "example.helper": { profiles } } } as never });
openSettings();
render(<Harness page="llm" />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
},
);
it("keeps Reset ownership on each page while merging plugin map writes", () => {
const view = render(<Harness page="templates" />);
fireEvent.change(screen.getAllByRole("textbox")[0]!, { target: { value: "docs" } });
view.rerender(<Harness page="llm" />);
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(new Set(Object.values(useSettingsDialog.getState().staged).map((c) => c.page)).size).toBe(2);
act(() => resetPage("plugins.example-helper.settings:templates"));
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toMatchObject({
"example.helper": { profiles: [{ name: "Local" }] },
});
expect(
(stagedValue(useSettingsDialog.getState(), "global", "plugins") as Record<string, Record<string, unknown>>)[
"example.helper"
]!.types,
).toBeUndefined();
act(() => resetPage("plugins.example-helper.settings:llm"));
expect(useSettingsDialog.getState().staged).toEqual({});
});
it.each(["llm", "templates"])("rejects oversized %s edits without discarding other rows", (page) => {
render(<Harness page={page} />);
if (page === "llm") {
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
}
const inputs = screen.getAllByRole("textbox");
const prior = inputs[0]!.getAttribute("value");
fireEvent.change(inputs[0]!, { target: { value: "é".repeat(32769) } });
expect(screen.getByRole("alert")).toHaveTextContent("The value exceeds this setting’s limits.");
expect(inputs[0]).toHaveValue(prior);
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getAllByRole("row")).toHaveLength(4);
expect(screen.getAllByRole("textbox")[0]).toHaveValue(prior);
});
it.each(["global", "project"] as const)("passes %s scope to the settings command", async (scope) => {
render(<Harness page="llm" scope={scope} />);
fireEvent.click(screen.getByRole("button", { name: "Test" }));
await waitFor(() => expect(run).toHaveBeenCalledWith("example.helper", "test", null, null, [], scope));
});
it("indexes string-list enum options", () => {
dispose();
dispose = projectSettings("example.helper", {
title: "Helper",
properties: [],
pages: [
{
id: "list",
title: "Types",
properties: [
{
key: "types",
title: "Types",
type: "array",
default: [],
items: {
key: "item",
title: "Item",
type: "string",
default: "feature",
options: ["feature", "documentation"],
},
},
],
},
],
});
expect(useSettingsPages.getState().pages.get("plugins.example-helper.settings:list")!.keywords).toContain(
"documentation",
);
});
+3 -2
Переглянути файл
@@ -62,6 +62,7 @@ export interface PluginsBackend {
arg: string | null,
editor: PluginEditor | null,
paths: string[],
settingsScope?: "global" | "project",
): Promise<string | null>;
/** Workbench events plugins subscribed to (see `eventKinds`). */
postEvents(events: WorkbenchEvent[]): Promise<void>;
@@ -184,8 +185,8 @@ export const tauriPluginsBackend: PluginsBackend = {
await call(commands.pluginUninstall(id));
},
reload: (id) => call(commands.pluginReload(id)),
runCommand: (pluginId, commandId, arg, editor, paths) =>
call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths)),
runCommand: (pluginId, commandId, arg, editor, paths, settingsScope) =>
call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths, settingsScope ?? null)),
postEvents: async (events) => {
await commands.pluginPostEvents(events);
},
+47
Переглянути файл
@@ -47,6 +47,53 @@ describe("localizeContributions", () => {
expect(out[3]).toBe(items[3]);
});
it("translates page, group, table-column and action text without changing values", () => {
const field = {
key: "Refresh",
type: "array",
title: "Word Tools",
group: "Refresh",
default: [{ name: "Cancel" }],
items: {
type: "object",
title: "Word Tools",
properties: [{ key: "name", title: "Cancel", description: "Refresh", options: ["Cancel"], default: "Cancel" }],
},
};
const declaration = {
title: "Word Tools",
properties: [field],
pages: [
{ id: "Refresh", title: "Cancel", properties: [field], actions: [{ title: "Refresh", command: "Refresh" }] },
],
};
const out = localizeContributions([item("a", "settings", declaration)], { a: fr })[0]!.decl;
expect(out).toMatchObject({
title: "Outils de mots",
pages: [
{
id: "Refresh",
title: "Annuler",
actions: [{ title: "Actualiser", command: "Refresh" }],
properties: [
{
key: "Refresh",
title: "Outils de mots",
group: "Actualiser",
default: [{ name: "Cancel" }],
items: {
properties: [
{ key: "name", title: "Annuler", description: "Actualiser", options: ["Cancel"], default: "Cancel" },
],
},
},
],
},
],
});
expect(declaration.pages[0]!.title).toBe("Cancel");
});
it("keeps the names of secrets and translates their labels", () => {
const secret = item("a", "secrets", { id: "k", name: "Refresh", label: "Cancel", description: "Refresh" });
expect(localizeContributions([secret], { a: fr })[0]!.decl).toMatchObject({
+4 -1
Переглянути файл
@@ -91,7 +91,10 @@ function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate
const out: Record<string, unknown> = { ...(decl as Record<string, unknown>) };
for (const key of keys) if (typeof out[key] === "string") out[key] = t(out[key]);
if (Array.isArray(out.properties))
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description"], t));
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description", "group"], t));
if (Array.isArray(out.pages)) out.pages = out.pages.map((p) => localizeDecl(p, ["title"], t));
if (Array.isArray(out.actions)) out.actions = out.actions.map((a) => localizeDecl(a, ["title"], t));
if (out.items) out.items = localizeDecl(out.items, ["title", "description", "group"], t);
if (Array.isArray(out.variables)) out.variables = out.variables.map((v) => localizeDecl(v, ["prompt"], t));
return out;
}
+346
Переглянути файл
@@ -0,0 +1,346 @@
/** IDE-rendered plugin settings; every control edits the dialog's staged form. */
import { useRef, useState } from "react";
import { tx } from "@/platform/i18n";
import { Button, Checkbox, IconButton, Input, Select, T } from "@/ui";
import { registerSettingsPage, useSettingsForm } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import styles from "./PluginSettings.module.css";
export interface SettingSchema {
key: string;
type: "boolean" | "string" | "number" | "integer" | "array" | "object";
title: string;
description?: string | null;
default: unknown;
options?: string[] | null;
multiline?: boolean;
group?: string | null;
items?: SettingSchema | null;
properties?: SettingSchema[];
}
interface SettingsAction {
title: string;
command: string;
}
interface SettingsPage {
id: string;
title: string;
properties: SettingSchema[];
actions?: SettingsAction[];
}
export interface SettingsDecl {
title: string;
properties: SettingSchema[];
pages?: SettingsPage[];
}
type PluginSettings = Record<string, Record<string, unknown>>;
/** `null` removes an override, restoring its default. */
export function withPluginSetting(
settings: PluginSettings | undefined,
pluginId: string,
key: string,
value: unknown,
): PluginSettings {
const own = { ...(settings?.[pluginId] ?? {}) };
if (value === null) delete own[key];
else own[key] = value;
const next = { ...(settings ?? {}) };
if (Object.keys(own).length) next[pluginId] = own;
else delete next[pluginId];
return next;
}
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
function ValueControl({
s,
value,
set,
label = s.title,
cell = false,
}: {
s: SettingSchema;
value: unknown;
set(value: unknown): void;
label?: string;
cell?: boolean;
}) {
if (s.type === "array") return <ArrayControl s={s} value={value as unknown[]} set={set} />;
if (s.type === "boolean")
return <Checkbox checked={value === true} label={cell ? undefined : s.title} aria-label={label} onChange={set} />;
if (s.options)
return (
<Select
aria-label={label}
value={String(value)}
options={s.options.map((o) => ({ value: o, label: o }))}
onChange={set}
/>
);
if (s.multiline && s.type === "string")
return (
<textarea
className={styles.multiline}
aria-label={label}
value={String(value ?? "")}
onChange={(e) => set(e.target.value)}
/>
);
return (
<Input
aria-label={label}
type={s.type === "string" ? "text" : "number"}
step={s.type === "integer" ? 1 : "any"}
value={String(value ?? "")}
onChange={(e) => {
const raw = e.target.value;
if (s.type === "string") set(raw);
else if (raw.trim() && Number.isFinite(Number(raw)))
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
}}
/>
);
}
function ArrayControl({ s, value, set }: { s: SettingSchema; value: unknown[]; set(value: unknown): void }) {
const item = s.items!;
const columns = item.type === "object" ? item.properties! : [item];
const rows = Array.isArray(value) ? value : [];
const update = (index: number, field: SettingSchema, next: unknown) =>
set(
rows.map((row, i) =>
i !== index ? row : item.type === "object" ? { ...(row as Record<string, unknown>), [field.key]: next } : next,
),
);
const move = (index: number, delta: number) => {
const next = [...rows];
[next[index], next[index + delta]] = [next[index + delta], next[index]];
set(next);
};
return (
<div className={styles.array}>
<div className={styles.scroll}>
<table aria-label={s.title}>
<thead>
<tr>
{columns.map((c) => (
<th key={c.key}>{item.type === "object" ? c.title : s.title}</th>
))}
<th>
<T>Actions</T>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c.key}>
<ValueControl
s={c}
cell
label={`${s.title}: ${c.title} ${i + 1}`}
value={item.type === "object" ? (row as Record<string, unknown>)[c.key] : row}
set={(v) => update(i, c, v)}
/>
</td>
))}
<td>
<div className={styles.buttons}>
<IconButton
size="small"
icon="arrow-up"
title="Move Up"
disabled={i === 0}
onClick={() => move(i, -1)}
/>
<IconButton
size="small"
icon="arrow-down"
title="Move Down"
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
/>
<IconButton
size="small"
icon="trash"
title="Remove"
onClick={() => set(rows.filter((_, index) => index !== i))}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
className={styles.add}
disabled={rows.length >= 1000}
onClick={() => set([...rows, structuredClone(item.default)])}
>
<T>Add</T>
</Button>
</div>
);
}
function accepts(s: SettingSchema, value: unknown): boolean {
if (s.type === "array")
return Array.isArray(value) && value.length <= 1000 && !!s.items && value.every((v) => accepts(s.items!, v));
if (s.type === "object")
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length === s.properties?.length &&
s.properties.every((p) => accepts(p, (value as Record<string, unknown>)[p.key]))
);
if (s.type === "boolean") return typeof value === "boolean";
if (s.type === "number" || s.type === "integer")
return typeof value === "number" && Number.isFinite(value) && (s.type !== "integer" || Number.isInteger(value));
return (
typeof value === "string" &&
new TextEncoder().encode(value).length <= 65536 &&
(!s.options || s.options.includes(value))
);
}
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
const [invalid, setInvalid] = useState(false);
const form = useSettingsForm();
const all = form.useValue("plugins") as PluginSettings | undefined;
const stored = all?.[pluginId]?.[s.key];
const value = accepts(s, stored) ? stored : s.default;
return (
<div className={styles.field}>
{s.type !== "boolean" && <span>{s.title}</span>}
<ValueControl
s={s}
value={value}
set={(v) => {
const valid = accepts(s, v);
setInvalid(!valid);
if (valid) form.setMapEntry("plugins", [pluginId, s.key], v === s.default ? null : v);
}}
/>
{s.description && <div className={styles.description}>{s.description}</div>}
{invalid && (
<p role="alert" className={styles.error}>
<T>The value exceeds this setting’s limits.</T>
</p>
)}
</div>
);
}
function PageAction({ pluginId, action }: { pluginId: string; action: SettingsAction }) {
const form = useSettingsForm();
const lock = useRef(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async () => {
if (lock.current) return;
lock.current = true;
setRunning(true);
setError(null);
try {
if (!(await form.apply())) throw new Error(tx`Could not apply settings.`);
await pluginsBackend().runCommand(pluginId, action.command, null, null, [], form.scope);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
lock.current = false;
setRunning(false);
}
};
return (
<div>
<Button disabled={running} onClick={() => void run()}>
<>{action.title}</>
</Button>
{error && (
<p role="alert" className={styles.error}>
{error}
</p>
)}
</div>
);
}
function settingsPage(pluginId: string, page: Pick<SettingsPage, "properties" | "actions">) {
return function PluginSettingsPage() {
const groups = new Map<string, SettingSchema[]>();
for (const s of page.properties) {
const key = s.group ?? "";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(s);
}
return (
<div className={`${styles.page} ${page.properties.some((s) => s.type === "array") ? "" : styles.scalars}`}>
<p className={styles.description}>
<T>Settings of the plugin; projects can override them.</T>
</p>
{[...groups].map(([title, properties]) => (
<section key={title} className={styles.group}>
{title && <h3>{title}</h3>}
{properties.map((s) => (
<SettingControl key={s.key} pluginId={pluginId} s={s} />
))}
</section>
))}
{!!page.actions?.length && (
<>
<p className={styles.description}>
<T>Running an action applies pending settings first.</T>
</p>
<div className={styles.buttons}>
{page.actions.map((a, index) => (
<PageAction key={index} pluginId={pluginId} action={a} />
))}
</div>
</>
)}
</div>
);
};
}
function keywords(page: Pick<SettingsPage, "properties" | "actions">): string[] {
const fieldWords = (s: SettingSchema): string[] => [
s.key,
s.title,
s.description ?? "",
s.group ?? "",
...(s.options ?? []),
...(s.items ? fieldWords(s.items) : []),
...(s.properties ?? []).flatMap(fieldWords),
];
return [...page.properties.flatMap(fieldWords), ...(page.actions ?? []).map((a) => a.title)];
}
export function projectSettings(pluginId: string, decl: SettingsDecl): () => void {
const id = pluginSettingsPageId(pluginId);
const pages = decl.pages ?? [];
const dispose = [
registerSettingsPage({
id,
title: decl.title,
projectScope: true,
keywords: ["plugin", pluginId, ...keywords(decl)],
component: pages.length ? undefined : settingsPage(pluginId, decl),
}),
];
for (const p of pages)
dispose.push(
registerSettingsPage({
id: `${id}.settings:${p.id}`,
title: p.title,
projectScope: true,
keywords: [pluginId, decl.title, ...keywords(p)],
component: settingsPage(pluginId, p),
}),
);
return () => dispose.forEach((d) => d());
}
+4 -115
Переглянути файл
@@ -14,22 +14,12 @@
* - `fileTemplates`: entries of the New menu;
* - `commitChecks`: before-commit checks listed in Commit Options, run by the plugin's code.
*/
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
import { getAction } from "@/platform/actions";
import { evaluateWhen, getContext, useContextKeys } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
import {
Checkbox,
Input,
Select,
T,
addFileIconProvider,
fileIconName,
registerExternalIcons,
type MenuItem,
} from "@/ui";
import { addFileIconProvider, fileIconName, registerExternalIcons, type MenuItem } from "@/ui";
import { addContributedFileTemplate } from "@/workbench/contributions";
import {
registerEditorKind,
@@ -38,9 +28,7 @@ import {
registerMenuGroup,
registerMenuSlotItem,
registerServiceProvider,
registerSettingsPage,
registerTextEditorWrapper,
useSettingsForm,
type ContextMenuId,
type ContributedCommitProblem,
type ToolWindowAnchor,
@@ -76,7 +64,7 @@ import { PluginView } from "./views/PluginView";
import { pluginServiceProvider } from "./views/serviceProvider";
import { WebView } from "./views/WebView";
import { registerLogActions } from "./views/logActions";
import styles from "./PluginsPage.module.css";
import { projectSettings, type SettingsDecl } from "./settings";
// --- menus ------------------------------------------------------------------------
@@ -274,99 +262,7 @@ interface StatusDecl {
// --- settings ---------------------------------------------------------------------
export interface SettingSchema {
key: string;
type: "boolean" | "string" | "number" | "integer";
title: string;
description?: string | null;
default: unknown;
options?: string[] | null;
multiline?: boolean;
}
interface SettingsDecl {
title: string;
properties: SettingSchema[];
}
type PluginSettings = Record<string, Record<string, unknown>>;
/** `settings` with `key` of `pluginId` set (`null`: back to the default). */
export function withPluginSetting(
settings: PluginSettings | undefined,
pluginId: string,
key: string,
value: unknown,
): PluginSettings {
const own = { ...(settings?.[pluginId] ?? {}) };
if (value === null) delete own[key];
else own[key] = value;
const next = { ...(settings ?? {}) };
if (Object.keys(own).length) next[pluginId] = own;
else delete next[pluginId];
return next;
}
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
const form = useSettingsForm();
const all = form.useValue("plugins") as PluginSettings | undefined;
const value = all?.[pluginId]?.[s.key] ?? s.default;
const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v));
const id = `plugin-setting-${pluginId}-${s.key}`;
const description = s.description ? <div className={styles.muted}>{s.description}</div> : null;
if (s.type === "boolean")
return (
<div className={styles.settingRow}>
<Checkbox checked={value === true} label={s.title} onChange={set} />
{description}
</div>
);
return (
<div className={styles.settingRow}>
<label htmlFor={id}>{s.title}</label>
{s.options ? (
<Select
aria-label={s.title}
value={String(value)}
options={s.options.map((o) => ({ value: o, label: o }))}
onChange={set}
/>
) : (
<Input
id={id}
type={s.type === "string" ? "text" : "number"}
value={String(value ?? "")}
onChange={(e) => {
const raw = e.target.value;
if (s.type === "string") set(raw);
else if (raw.trim() !== "" && Number.isFinite(Number(raw)))
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
}}
/>
)}
{description}
</div>
);
}
function settingsPage(pluginId: string, decl: SettingsDecl) {
return function PluginSettingsPage() {
const properties = useMemo(() => decl.properties, []);
return (
<div className={styles.settingsPage}>
<p className={styles.muted}>
<T>Settings of the plugin; projects can override them.</T>
</p>
{properties.map((s) => (
<SettingControl key={s.key} pluginId={pluginId} s={s} />
))}
</div>
);
};
}
/** Settings page id of a plugin (dots nest pages, so they become dashes). */
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
export { withPluginSetting, pluginSettingsPageId } from "./settings";
// --- views and custom editors -----------------------------------------------------
@@ -544,14 +440,7 @@ export function defineViewUiPoints(): () => void {
}),
defineUiPoint<SettingsDecl>("settings", {
title: "Settings",
project: ({ pluginId, decl }) =>
registerSettingsPage({
id: pluginSettingsPageId(pluginId),
title: decl.title,
projectScope: true,
keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])],
component: settingsPage(pluginId, decl),
}),
project: ({ pluginId, decl }) => projectSettings(pluginId, decl),
}),
defineUiPoint<ViewDecl>("views", {
orderSensitive: true,