дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
99102d8a40
коміт
905a672bad
41 files changed
+1887
-161
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача