дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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
@@ -1075,7 +1075,7 @@ export const commands = {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null, paths: string[] | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths })),
|
||||
} | null, paths: string[] | null, settingsScope: "global" | "project" | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths, settingsScope })),
|
||||
/** Workbench events from this window (only the kinds of `plugin_event_kinds`). */
|
||||
pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE<void>("plugin_post_events", { events }),
|
||||
/** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resetPage,
|
||||
stage,
|
||||
stageSshSecret,
|
||||
stageMapEntry,
|
||||
SSH_SETTINGS_PAGE,
|
||||
stagedValue,
|
||||
useSettingsDialog,
|
||||
@@ -304,3 +305,37 @@ describe("validateSettings", () => {
|
||||
expect(getAt(out, "editor.nope.deeper")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("literal map entry staging", () => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS) as Settings, project: null });
|
||||
openSettings();
|
||||
});
|
||||
it("preserves literal dots, per-page ownership, and merges one write per scope", async () => {
|
||||
stageMapEntry("templates", "global", "plugins", ["example.helper", "prompt.with.dots"], ["one"]);
|
||||
stageMapEntry("llm", "global", "plugins", ["example.helper", "profiles"], [{ name: "Local" }]);
|
||||
stageMapEntry("other", "project", "plugins", ["other.plugin", "enabled"], true);
|
||||
expect(pendingChanges(useSettingsDialog.getState())).toHaveLength(2);
|
||||
resetPage("llm");
|
||||
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toEqual({
|
||||
"example.helper": { "prompt.with.dots": ["one"] },
|
||||
});
|
||||
const backend = fakeBackend();
|
||||
expect(await applySettings(backend.deps)).toBe(true);
|
||||
expect(backend.set.mock.calls.map((c) => [c[0], c[1], JSON.parse(c[2])])).toEqual([
|
||||
["global", "plugins", { "example.helper": { "prompt.with.dots": ["one"] } }],
|
||||
["project", "plugins", { "other.plugin": { enabled: true } }],
|
||||
]);
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
it("removes only its own override and clears a reverted edit", () => {
|
||||
const base = validateSettings({ plugins: { "example.helper": { "a.b": 1, keep: true } } });
|
||||
useSettingsDialog.setState({ base, globalBase: base });
|
||||
stageMapEntry("a", "global", "plugins", ["example.helper", "a.b"], null);
|
||||
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toEqual({
|
||||
"example.helper": { keep: true },
|
||||
});
|
||||
stageMapEntry("a", "global", "plugins", ["example.helper", "a.b"], 1);
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
/** 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 { externalKey, stage, stageExternal, stagedValue, useSettingsDialog, type Scope } from "../model/staging";
|
||||
import {
|
||||
applySettings,
|
||||
externalKey,
|
||||
stage,
|
||||
stageMapEntry,
|
||||
stageExternal,
|
||||
stagedValue,
|
||||
useSettingsDialog,
|
||||
type Scope,
|
||||
} from "../model/staging";
|
||||
import { labelMatches } from "../model/pages";
|
||||
|
||||
export interface PageContextValue {
|
||||
@@ -36,8 +45,10 @@ 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),
|
||||
}),
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface CustomKeymapSettings {
|
||||
}
|
||||
|
||||
export interface ValidSettings extends WorkflowSettings {
|
||||
plugins: Record<string, Record<string, unknown>>;
|
||||
general: { reopenLastProject: boolean; confirmExit: boolean } & GeneralExtras;
|
||||
appearance: { theme: ThemeId; accent: string; uiFontSize: number; locale: Locale } & AppearanceExtras;
|
||||
editor: {
|
||||
@@ -121,6 +122,7 @@ function validateSpelling(raw: unknown): SpellingValid {
|
||||
export { ACCENT_PRESETS } from "@/workbench/theme";
|
||||
|
||||
export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
plugins: {},
|
||||
general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS },
|
||||
appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS },
|
||||
editor: {
|
||||
@@ -213,6 +215,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
customKeymaps[id] = { name: str(k.name, id), parent: str(k.parent, d.keymap), overrides: bindingMap(k.overrides) };
|
||||
}
|
||||
return {
|
||||
plugins: Object.fromEntries(Object.entries(obj(root.plugins)).map(([id, values]) => [id, obj(values)])),
|
||||
general: {
|
||||
...validateGeneralExtras(g),
|
||||
reopenLastProject: bool(g.reopenLastProject, d.general.reopenLastProject),
|
||||
|
||||
@@ -13,6 +13,8 @@ export type Scope = "global" | "project";
|
||||
export interface StagedChange {
|
||||
scope: Scope;
|
||||
key: string;
|
||||
/** Literal map segments; dots inside plugin IDs and keys are data. */
|
||||
path?: string[];
|
||||
value: unknown;
|
||||
/** Page that made the change (Reset discards per page). */
|
||||
page: string;
|
||||
@@ -217,17 +219,67 @@ export function setPageScope(pageId: string, scope: Scope) {
|
||||
|
||||
const isInside = (key: string, parent: string) => key.startsWith(`${parent}.`);
|
||||
|
||||
function mapEntry(value: unknown, path: readonly string[]): unknown {
|
||||
for (const key of path)
|
||||
value =
|
||||
value && typeof value === "object" && Object.hasOwn(value, key)
|
||||
? (value as Record<string, unknown>)[key]
|
||||
: undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function putEntry(value: unknown, path: readonly string[], next: unknown): unknown {
|
||||
const [head, ...tail] = path;
|
||||
if (head === undefined) return next;
|
||||
const out = { ...(value as Record<string, unknown> | undefined) };
|
||||
const child = tail.length ? putEntry(out[head], tail, next) : next;
|
||||
if (child == null || (tail.length && typeof child === "object" && !Object.keys(child).length)) delete out[head];
|
||||
else Object.defineProperty(out, head, { value: child, enumerable: true, configurable: true, writable: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge literal map entries only at the persistence boundary; their page ownership stays separate. */
|
||||
function mergedChanges(s: Bases & Pick<SettingsDialogState, "staged">): StagedChange[] {
|
||||
const all = Object.values(s.staged);
|
||||
const roots = new Map(all.filter((c) => !c.path).map((c) => [stageKey(c.scope, c.key), { ...c }]));
|
||||
for (const c of all) {
|
||||
if (!c.path) continue;
|
||||
const id = stageKey(c.scope, c.key);
|
||||
const root = roots.get(id) ?? {
|
||||
scope: c.scope,
|
||||
key: c.key,
|
||||
page: c.page,
|
||||
value: getAt(baseFor(s, c.scope), c.key),
|
||||
};
|
||||
root.value = putEntry(root.value, c.path, c.value);
|
||||
roots.set(id, root);
|
||||
}
|
||||
return [...roots.values()];
|
||||
}
|
||||
|
||||
/** Stage one literal map entry independently of every other page. */
|
||||
export function stageMapEntry(page: string, scope: Scope, key: string, path: string[], value: unknown) {
|
||||
if (!path.length) return stage(page, scope, key, value);
|
||||
useSettingsDialog.setState((s) => {
|
||||
const staged = { ...s.staged };
|
||||
const id = `${stageKey(scope, key)}|${JSON.stringify(path)}`;
|
||||
const next = value ?? null;
|
||||
if (deepEqual(next ?? undefined, mapEntry(getAt(baseFor(s, scope), key), path))) delete staged[id];
|
||||
else staged[id] = { scope, key, path, value: next, page };
|
||||
return { staged, error: null };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current value of `key` as the page shows it: staged edit, else the effective
|
||||
* value. Edits staged for a parent (a whole map entry) or inside `key` (one
|
||||
* field of it) show through too.
|
||||
*/
|
||||
export function stagedValue(s: Bases & Pick<SettingsDialogState, "staged">, scope: Scope, key: string): unknown {
|
||||
const hit = s.staged[stageKey(scope, key)];
|
||||
const changes = mergedChanges(s);
|
||||
const hit = changes.find((c) => c.scope === scope && c.key === key);
|
||||
if (hit) return hit.value;
|
||||
const related = Object.values(s.staged).filter(
|
||||
(c) => c.scope === scope && (isInside(key, c.key) || isInside(c.key, key)),
|
||||
);
|
||||
const related = changes.filter((c) => c.scope === scope && (isInside(key, c.key) || isInside(c.key, key)));
|
||||
let value = getAt(baseFor(s, scope), key);
|
||||
for (const c of related.sort((a, b) => a.key.length - b.key.length)) {
|
||||
value = isInside(key, c.key)
|
||||
@@ -251,7 +303,8 @@ export function stage(page: string, scope: Scope, key: string, value: unknown) {
|
||||
staged[stageKey(scope, parent.key)] = { ...parent, value: merged };
|
||||
return { staged, error: null };
|
||||
}
|
||||
for (const [id, c] of Object.entries(staged)) if (c.scope === scope && isInside(c.key, key)) delete staged[id];
|
||||
for (const [id, c] of Object.entries(staged))
|
||||
if (c.scope === scope && (isInside(c.key, key) || (c.key === key && c.path))) delete staged[id];
|
||||
const id = stageKey(scope, key);
|
||||
const next = value === undefined ? null : value;
|
||||
// Back to the scope's current value: nothing to write.
|
||||
@@ -296,7 +349,7 @@ export function resetPage(page: string) {
|
||||
|
||||
/** Writes to perform, global layer first so a project value still wins afterwards. */
|
||||
export function pendingChanges(s: Bases & Pick<SettingsDialogState, "staged">): StagedChange[] {
|
||||
return Object.values(s.staged)
|
||||
return mergedChanges(s)
|
||||
.filter((c) => !deepEqual(c.value ?? undefined, getAt(baseFor(s, c.scope), c.key)))
|
||||
.sort((a, b) => (a.scope === b.scope ? a.key.localeCompare(b.key) : a.scope === "global" ? -1 : 1));
|
||||
}
|
||||
@@ -375,7 +428,9 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise<
|
||||
.filter((c) => c.scope === "global" && done.has(stageKey(c.scope, c.key)))
|
||||
.reduce((g, c) => putAt(g, c.key, c.value), st.globalBase)
|
||||
: base,
|
||||
staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))),
|
||||
staged: Object.fromEntries(
|
||||
Object.entries(st.staged).filter(([id, c]) => !done.has(stageKey(c.scope, c.key)) || initial.staged[id] !== c),
|
||||
),
|
||||
}));
|
||||
await loadGlobalBase();
|
||||
if (!error) {
|
||||
|
||||
@@ -44,6 +44,10 @@ export function TestSettingsForm({
|
||||
const form = useMemo<SettingsForm>(
|
||||
() => ({
|
||||
scope,
|
||||
apply: async () => true,
|
||||
setMapEntry: () => {
|
||||
throw new Error("Map entries are not supported by this stand-in");
|
||||
},
|
||||
useExternal: () => {
|
||||
throw new Error("External settings are not supported by this stand-in");
|
||||
},
|
||||
|
||||
@@ -396,5 +396,8 @@
|
||||
"Modified": "Modified",
|
||||
"Before": "Before",
|
||||
"After": "After",
|
||||
"The plugin did not save the content": "The plugin did not save the content"
|
||||
"The plugin did not save the content": "The plugin did not save the content",
|
||||
"Running an action applies pending settings first.": "Running an action applies pending settings first.",
|
||||
"Could not apply settings.": "Could not apply settings.",
|
||||
"The value exceeds this setting’s limits.": "The value exceeds this setting’s limits."
|
||||
}
|
||||
@@ -6749,5 +6749,8 @@
|
||||
"Run Content Editor Action": "运行内容编辑器操作",
|
||||
"Before": "更改前",
|
||||
"After": "更改后",
|
||||
"The plugin did not save the content": "插件未保存内容"
|
||||
"The plugin did not save the content": "插件未保存内容",
|
||||
"Running an action applies pending settings first.": "运行动作前会先应用待保存的设置。",
|
||||
"Could not apply settings.": "无法应用设置。",
|
||||
"The value exceeds this setting’s limits.": "该值超出了此设置的限制。"
|
||||
}
|
||||
@@ -905,11 +905,15 @@ export interface SettingsPageContribution {
|
||||
* edits not applied yet. Edits are written on Apply / OK and dropped on Cancel.
|
||||
*/
|
||||
export interface SettingsForm {
|
||||
/** Applies pending edits; false prevents an action from running after a failed save. */
|
||||
apply(): Promise<boolean>;
|
||||
scope: "global" | "project";
|
||||
/** Value of a dotted key (map entries too, e.g. `languageServers.ruff.args`). */
|
||||
useValue(key: string): unknown;
|
||||
/** Stages a value; `null` removes the key on Apply. */
|
||||
set(key: string, value: unknown): void;
|
||||
/** Stages a literal map entry with independent page/Reset ownership. */
|
||||
setMapEntry(key: string, path: string[], value: unknown): void;
|
||||
/** Values stored outside settings JSON; the writer runs on Apply / OK and returns the saved value. */
|
||||
useExternal<T>(
|
||||
key: string,
|
||||
|
||||
Reference in new issue
Заблокувати користувача