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]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 08:38:42 +08:00
1 parent 99102d8a40
commit 905a672bad
41 files changed
+1887 -161

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
+1
View File
@@ -117,6 +117,7 @@ pub async fn plugin_navigation_index(
workspace_root: Some(project.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)?;
// A document edit while WASM was executing must not publish stale ranges.
+1
View File
@@ -1811,6 +1811,7 @@ impl PluginHost {
client: client.clone(),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
};
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
let filesystem = kinds.contains(&EventKind::FilesChanged);
@@ -31,6 +31,7 @@ use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use ide_workspace::settings::SettingsScope;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -376,11 +377,13 @@ impl RemotePlugins {
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
selected_paths: paths.unwrap_or_default(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
+257 -10
View File
@@ -283,12 +283,60 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: Option<PathBuf>,
#[serde(default)]
pages: Vec<SettingsPage>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsPage {
id: String,
title: String,
schema: PathBuf,
#[serde(default)]
actions: Vec<SettingsAction>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SettingsAction {
title: String,
command: String,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
properties: serde_json::Map<String, Value>,
#[serde(deserialize_with = "ordered_properties")]
properties: Vec<(String, Box<serde_json::value::RawValue>)>,
}
// Preserve schema field order locally, without changing serde_json maps throughout the host.
fn ordered_properties<'de, D>(deserializer: D) -> Result<Vec<(String, Box<serde_json::value::RawValue>)>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Properties;
impl<'de> serde::de::Visitor<'de> for Properties {
type Value = Vec<(String, Box<serde_json::value::RawValue>)>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an object of setting schemas")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut fields = Vec::new();
let mut keys = BTreeSet::new();
while let Some((key, value)) = map.next_entry::<String, Box<serde_json::value::RawValue>>()? {
if !keys.insert(key.clone()) {
return Err(serde::de::Error::custom(format!("duplicate setting key {key:?}")));
}
fields.push((key, value));
}
Ok(fields)
}
}
deserializer.deserialize_map(Properties)
}
/// One setting of a plugin.
@@ -296,7 +344,7 @@ struct SchemaFile {
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// `boolean`, `string`, `number` or `integer`.
/// A scalar, top-level `array`, or `object` table row.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
@@ -307,6 +355,12 @@ pub struct SettingSchema {
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
}
impl SettingSchema {
@@ -316,6 +370,13 @@ impl SettingSchema {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
"array" => v.as_array().is_some_and(|a| {
a.len() <= 1000 && self.items.as_ref().is_some_and(|item| a.iter().all(|v| item.accepts(v)))
}),
"object" => v.as_object().is_some_and(|o| {
o.len() == self.properties.len()
&& self.properties.iter().all(|p| o.get(&p.key).is_some_and(|v| p.accepts(v)))
}),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
@@ -323,17 +384,47 @@ impl SettingSchema {
}
}
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result<SettingSchema, String> {
setting_at(key, raw, 0)
}
fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let value: Value = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let o = value.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let mut items = None;
let mut properties = Vec::new();
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
"array" if depth == 0 => {
#[derive(Deserialize)]
struct ArraySchema {
items: Box<serde_json::value::RawValue>,
}
let array: ArraySchema = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let item = setting_at("item", &array.items, 1)?;
if item.kind != "string" && item.kind != "object" {
return Err(format!("setting {key:?}: items must be strings or objects"));
}
items = Some(Box::new(item));
o.get("default").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
}
"object" if depth == 1 => {
let fields = serde_json::from_str::<SchemaFile>(raw.get()).map_err(|e| e.to_string())?.properties;
if fields.is_empty() || fields.len() > MAX_SETTINGS {
return Err(format!("setting {key:?}: table needs 1–{MAX_SETTINGS} columns"));
}
properties = fields.iter().map(|(k, v)| setting_at(k, v, 2)).collect::<Result<_, _>>()?;
o.get("default").cloned().unwrap_or_else(|| {
Value::Object(properties.iter().map(|p| (p.key.clone(), p.default.clone())).collect())
})
}
_ => return Err(format!("setting {key:?}: unsupported setting type {kind:?}")),
};
let options = match o.get("enum") {
None => None,
@@ -359,6 +450,9 @@ fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
items,
properties,
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
@@ -377,13 +471,68 @@ pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
if d.schema.is_some() == !d.pages.is_empty() {
return Err("settings: use either `schema` or nonempty `pages`".into());
}
if d.pages.len() > 32 {
return Err("settings: at most 32 pages".into());
}
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
let mut files = Vec::new();
let mut keys = BTreeSet::new();
let mut load = |path: &Path| -> Result<Vec<SettingSchema>, String> {
let properties =
parse_settings_schema(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
for p in &properties {
if !keys.insert(p.key.clone()) {
return Err(format!("settings: duplicate key {:?} across pages", p.key));
}
if p.group.is_some() || p.kind == "array" {
pkg.manifest.needs_api("settings: groups / arrays", 3)?;
}
}
if keys.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
files.push(path.to_path_buf());
Ok(properties)
};
let mut properties = Vec::new();
let mut pages = Vec::new();
if let Some(path) = d.schema {
properties = load(&path)?;
} else {
pkg.manifest.needs_api("contributes.settings.pages", 3)?;
let mut ids = BTreeSet::new();
for p in d.pages {
validate_item_id(&p.id, "settings page id")?;
if p.id.contains('.') || !ids.insert(p.id.clone()) {
return Err(format!("settings: page id {:?} must be unique and contain no dots", p.id));
}
title_ok(&p.title)?;
if p.actions.len() > 32 {
return Err("settings: at most 32 actions per page".into());
}
for a in &p.actions {
title_ok(&a.title)?;
declared_command(pkg, SETTINGS, &a.command)?;
}
let fields = load(&p.schema)?;
properties.extend(fields.clone());
pages.push(serde_json::json!({
"id": p.id, "title": p.title, "properties": fields, "actions": p.actions,
}));
}
}
let mut decl = UiDecl::new(
"settings",
title.clone(),
serde_json::json!({
"title": title, "properties": properties, "pages": pages,
}),
);
decl.files = files;
Ok(decl)
}
@@ -810,6 +959,104 @@ mod tests {
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
#[test]
fn collections_validate_every_cell_and_keep_schema_order() {
let schema =
parse_settings_schema(include_str!("../../../ide-plugin/tests/fixtures/settings-pages/llm.json")).unwrap();
let profiles = &schema[0];
let item = profiles.items.as_ref().unwrap();
assert_eq!(
item.properties.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(),
["name", "provider", "baseUrl", "model", "enabled", "temperature", "limit"]
);
assert!(profiles.accepts(&serde_json::json!([item.default])));
for (field, value) in [
("provider", Value::from("unknown")),
("enabled", Value::from("true")),
("limit", Value::from(1.5)),
("model", Value::Null),
] {
let mut row = item.default.clone();
row[field] = value;
assert!(!profiles.accepts(&serde_json::json!([row])), "{field}");
}
assert!(!profiles.accepts(&serde_json::json!([{}])));
for items in [
r#"{"type":"number"}"#,
r#"{"type":"array","items":{"type":"string"}}"#,
r#"{"type":"object","properties":{}}"#,
r#"{"type":"object","properties":{"nested":{"type":"object"}}}"#,
] {
assert!(
parse_settings_schema(&format!(r#"{{"properties":{{"x":{{"type":"array","items":{items}}}}}}}"#))
.is_err()
);
}
let lists = parse_settings_schema(
r#"{"properties":{"z":{"type":"array","items":{"type":"string"}},"a":{"type":"boolean"}}}"#,
)
.unwrap();
assert_eq!(lists[0].key, "z");
assert!(lists[0].accepts(&serde_json::json!(["feat", "fix"])));
assert!(!lists[0].accepts(&serde_json::json!([1])));
}
#[test]
fn schema_order_is_local_and_duplicate_fields_are_rejected() {
let raw = r#"{"properties":{"z":{"type":"string"},"a":{"type":"boolean"}}}"#;
let fields = parse_settings_schema(raw).unwrap();
assert_eq!(fields.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["z", "a"]);
let ordinary: Value = serde_json::from_str(raw).unwrap();
assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::<Vec<_>>(), ["a", "z"]);
for raw in [
r#"{"properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}"#,
r#"{"properties":{"rows":{"type":"array","items":{"type":"object","properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}}}}"#,
] {
assert!(parse_settings_schema(raw).unwrap_err().contains("duplicate setting key"));
}
}
#[test]
fn settings_pages_validate_manifest_files_commands_and_api() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/settings-pages");
let source = std::fs::read_to_string(root.join("plugin.toml")).unwrap();
let check = |source: &str| {
let manifest = ide_extension::Manifest::parse(source)?;
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
settings(&Package::new(manifest, root.clone()), decl)
};
let valid = check(&source).unwrap();
assert_eq!(valid.json["pages"].as_array().unwrap().len(), 2);
assert_eq!(valid.json["properties"].as_array().unwrap().len(), 3);
assert_eq!(valid.files.len(), 2);
for bad in [
source.replace("^1.3", "^1.2"),
source.replace(r#"id = "llm""#, r#"id = "templates""#),
source.replace(r#"command = "test""#, r#"command = "missing""#),
source.replace("llm.json", "missing.json"),
source.replace("llm.json", "../outside.json"),
source.replace("llm.json", "templates.json"),
source.replace(r#"id = "llm""#, r#"id = "llm.child""#),
] {
assert!(check(&bad).is_err(), "{bad}");
}
let old = source.split("[contributes.settings]").next().unwrap().replace("^1.3", "^1.0");
let old = format!("{old}\n[contributes.settings]\nschema = \"legacy.json\"\n");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("legacy.json"), r#"{"properties":{"enabled":{"type":"boolean"}}}"#).unwrap();
let manifest = ide_extension::Manifest::parse(&old).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok());
for field in [r#"{"type":"string","group":"Provider"}"#, r#"{"type":"array","items":{"type":"string"}}"#] {
std::fs::write(dir.path().join("legacy.json"), format!(r#"{{"properties":{{"x":{field}}}}}"#)).unwrap();
for (api, valid) in [("^1.0", false), ("^1.2", false), ("^1.3", true)] {
let manifest = ide_extension::Manifest::parse(&old.replace("^1.0", api)).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert_eq!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok(), valid);
}
}
}
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
+7 -1
View File
@@ -58,7 +58,13 @@ fn fixture() -> Fixture {
}
fn inv() -> Invocation {
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
Invocation {
client: Some("main".into()),
workspace_root: None,
editor: None,
selected_paths: Vec::new(),
global_settings: false,
}
}
fn misbehave() -> PathBuf {
+1
View File
@@ -191,6 +191,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
workspace_root: Some(f.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)
.unwrap();
+25 -2
View File
@@ -29,6 +29,7 @@ struct TestWorkspace {
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
/// plugin → its settings as the user set them.
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
}
impl TestWorkspace {
@@ -46,6 +47,7 @@ impl TestWorkspace {
event_read_gate: Mutex::new(None),
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
})
}
@@ -125,8 +127,9 @@ impl PluginWorkspace for TestWorkspace {
self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items);
Ok(())
}
fn plugin_settings(&self, plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
self.settings.lock().get(plugin).cloned().unwrap_or_default()
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let layer = if root.is_some() { &self.settings } else { &self.global_settings };
layer.lock().get(plugin).cloned().unwrap_or_default()
}
}
@@ -286,6 +289,7 @@ impl Fixture {
workspace_root: Some(self.project.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
}
}
@@ -2454,6 +2458,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
event_read_gate: Mutex::default(),
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
});
f.host.set_workspace(Some(untrusted));
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
@@ -3058,6 +3063,24 @@ fn official_regex_tester_answers_its_page() {
assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4]));
}
#[test]
fn settings_actions_choose_their_layer_without_losing_the_project() {
let f = fixture(Limits::default());
f.install(&sample("settings-pages"));
for (global, name) in [(false, "Project"), (true, "Global")] {
let values = serde_json::json!({ "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] });
let layer = if global { &f.workspace.global_settings } else { &f.workspace.settings };
layer.lock().insert("settings-pages".into(), values.as_object().unwrap().clone());
}
for (global_settings, name) in [(false, "Project"), (true, "Global"), (false, "Project")] {
let inv = Invocation { global_settings, ..f.inv() };
let result = f.host.run_command("settings-pages", "test", None, inv).unwrap().unwrap();
let result: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(result["profiles"][0]["name"], name);
assert_eq!(result["hasProject"], true);
}
}
/// A tree filled from a pinned program's output, and a terminal for the
/// activated entry in the invoking window, titled and sized by the plugin's
/// settings (the `jobs` fixture).
+22 -3
View File
@@ -233,7 +233,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for dir in [official("csv-editor"), fixture("jobs")] {
for dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] {
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
@@ -269,8 +269,8 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs", "settings-pages"]).await;
assert_eq!(report.installed.len(), 3, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
@@ -329,5 +329,24 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
.await
.unwrap();
assert_eq!(selected, "/srv/src/a.txt");
for (scope, name) in [("global", "Global"), ("project", "Project")] {
let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } });
invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() }))
.await
.unwrap();
}
for (scope, name) in
[(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")]
{
let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null });
if let Some(scope) = scope {
args["settingsScope"] = json!(scope);
}
let result = invoke(&peer, "plugin_run_command", args).await.unwrap();
let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
assert_eq!(result["profiles"][0]["name"], name);
assert_eq!(result["hasProject"], true);
}
plugins.host().shutdown();
}
+2
View File
@@ -27,6 +27,8 @@ pub struct EditorContext {
pub struct Invocation {
pub client: Option<String>,
pub workspace_root: Option<PathBuf>,
/// Settings actions can read the global layer while retaining their project context.
pub global_settings: bool,
pub editor: Option<EditorContext>,
/// Files and folders the command was run on.
pub selected_paths: Vec<PathBuf>,
+1 -1
View File
@@ -1177,7 +1177,7 @@ impl views::Host for StoreData {
impl settings::Host for StoreData {
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
let root = self.invocation.workspace_root.clone();
let root = if self.invocation.global_settings { None } else { self.invocation.workspace_root.clone() };
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
}
}
+337
View File
@@ -0,0 +1,337 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-test-settings-pages"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,21 @@
[package]
name = "openidea-test-settings-pages"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
serde_json = "1"
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
@@ -0,0 +1,17 @@
# Multi-page settings example (API 1.3)
Two IDE-rendered pages demonstrate grouped string lists and an editable model
profile table. `Test` first applies pending settings, then runs the declared
`test` command. With no profiles it returns an inline error; after adding a row
it reads the saved JSON array and reports success through a notification.
This fixture checks the settings/action flow; it does not contact a provider.
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/settings-pages`.
Install or link this directory with `openidea-plugin link` and open
Settings › Plugins › Model Profiles. Edit without Apply and Cancel to discard;
Apply or OK saves. Running Test also saves pending edits before the command,
so Cancel after Test does not undo those already-applied edits.
API keys belong in `[[contributes.secrets]]`, never table cells. A real Test
connection command can use `host::fetch` with the appropriate declared network
capability and secret-backed headers.
Binary file not shown.
@@ -0,0 +1 @@
42848c3b8b0aeaee3e861c8b76b85d13d28374572fc6e17e059b2d2015fbac17
@@ -0,0 +1,19 @@
{
"properties": {
"profiles": {
"type": "array", "title": "Model profiles", "group": "Provider",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name", "default": "Local" },
"provider": { "type": "string", "title": "Provider", "enum": ["local", "openai"], "default": "local" },
"baseUrl": { "type": "string", "title": "Base URL", "default": "http://localhost:11434" },
"model": { "type": "string", "title": "Model", "default": "example-model" },
"enabled": { "type": "boolean", "title": "Enabled", "default": true },
"temperature": { "type": "number", "title": "Temperature", "default": 0.7 },
"limit": { "type": "integer", "title": "Token limit", "default": 2048 }
}
}
}
}
}
@@ -0,0 +1,27 @@
id = "settings-pages"
name = "Model Profiles"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[[contributes.commands]]
id = "test"
title = "Test model profile"
[contributes.settings]
title = "Model Profiles"
[[contributes.settings.pages]]
id = "templates"
title = "Commit Template"
schema = "templates.json"
[[contributes.settings.pages]]
id = "llm"
title = "LLM Settings"
schema = "llm.json"
[[contributes.settings.pages.actions]]
title = "Test"
command = "test"
@@ -0,0 +1,24 @@
//! Settings action example. A real connection test can use host::fetch with a
//! declared network capability and a secret-backed Authorization header.
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Profiles;
impl Plugin for Profiles {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
if id != "test" {
return Err(Error::Invalid("unknown command".into()));
}
let json = host::setting("profiles").unwrap_or_else(|| "[]".into());
let profiles: serde_json::Value = serde_json::from_str(&json).map_err(|e| Error::Invalid(e.to_string()))?;
let rows = profiles.as_array().ok_or("model profiles must be an array")?;
if rows.is_empty() {
return Err(Error::Invalid("Add a model profile before testing".into()));
}
host::notify(NotificationLevel::Info, "Model Profiles", &format!("Read {} saved profile(s)", rows.len()));
Ok(Some(serde_json::json!({ "profiles": profiles, "hasProject": ctx.project().is_some() }).to_string()))
}
}
register_plugin!(Profiles);
@@ -0,0 +1,12 @@
{
"properties": {
"templates": {
"type": "array", "title": "Commit templates", "group": "Templates",
"items": { "type": "string" }, "default": ["{type}: {summary}"]
},
"types": {
"type": "array", "title": "Allowed commit types", "group": "Templates",
"items": { "type": "string" }, "default": ["feat", "fix", "docs"]
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import { readFileSync } from "node:fs";
import { expect, test } from "./fixtures";
import type { SettingSchema } from "../src/features/plugins/settings";
type SchemaField = Omit<SettingSchema, "key" | "title" | "default" | "items" | "properties"> & {
title?: string;
default?: unknown;
enum?: string[];
items?: SchemaField;
properties?: Record<string, SchemaField>;
};
const fixture = "crates/ide-plugin/tests/fixtures/settings-pages/";
const fields = (name: string) =>
Object.entries(
(JSON.parse(readFileSync(`${fixture}${name}.json`, "utf8")) as { properties: Record<string, SchemaField> })
.properties,
).map(([key, value]) => {
const convert = (key: string, value: SchemaField): SettingSchema => ({
...value,
key,
options: value.enum,
title: value.title ?? key,
default:
value.default ??
(value.type === "array"
? []
: value.type === "object"
? Object.fromEntries(Object.entries(value.properties ?? {}).map(([k, v]) => [k, convert(k, v).default]))
: value.type === "boolean"
? false
: value.type === "string"
? ""
: 0),
items: value.items ? convert("item", value.items) : undefined,
properties: value.properties ? Object.entries(value.properties ?? {}).map(([k, v]) => convert(k, v)) : undefined,
});
return convert(key, value);
});
test("plugin settings pages stage edits, apply before actions and discard on Cancel", async ({ workbench: page }) => {
const templates = fields("templates");
const llm = fields("llm");
await page.evaluate(
async ({ templates, llm }) => {
const module = "/src/features/plugins/projection.ts";
const { uiPoint } = await import(/* @vite-ignore */ module);
const decl = {
title: "Model Profiles",
properties: [...templates, ...llm],
pages: [
{ id: "templates", title: "Commit Template", properties: templates },
{ id: "llm", title: "LLM Settings", properties: llm, actions: [{ title: "Test", command: "test" }] },
],
};
uiPoint("settings").project({ pluginId: "settings-pages", point: "settings", key: "settings", decl }, {});
{
const backendModule = "/src/features/plugins/backend.ts";
const { pluginsBackend } = await import(/* @vite-ignore */ backendModule);
pluginsBackend().runCommand = async () => {
throw new Error("Example provider unavailable");
};
}
},
{ templates, llm },
);
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
const search = dialog.getByRole("textbox", { name: "Search settings" });
await search.fill("LLM Settings");
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
await search.clear();
await dialog.getByRole("treeitem", { name: "Plugins", exact: true }).click();
await page.keyboard.press("ArrowRight");
await dialog.getByRole("treeitem", { name: "Model Profiles", exact: true }).click();
await page.keyboard.press("ArrowRight");
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
await dialog.getByRole("button", { name: "Add", exact: true }).click();
const name = dialog.getByRole("textbox", { name: "Model profiles: Name 1", exact: true });
await name.fill("Cloud");
if (process.env.SETTINGS_EVIDENCE)
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-1.png`, animations: "disabled" });
await dialog.getByRole("button", { name: "Test", exact: true }).click();
await expect(dialog.getByRole("alert")).toHaveText("Example provider unavailable");
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
if (process.env.SETTINGS_EVIDENCE)
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-2.png`, animations: "disabled" });
await name.fill("Discard me");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await page.keyboard.press("Meta+,");
await expect(name).toHaveValue("Cloud");
await search.fill("Commit Template");
await dialog.getByRole("treeitem", { name: "Commit Template", exact: true }).click();
await search.clear();
await expect(dialog.getByRole("table", { name: "Allowed commit types" })).toBeVisible();
if (process.env.SETTINGS_EVIDENCE)
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-3.png`, animations: "disabled" });
const types = dialog.getByRole("table", { name: "Allowed commit types" }).getByRole("textbox");
await types.first().fill("docs");
await search.fill("LLM Settings");
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
await search.clear();
await name.fill("Reset only this page");
await dialog.getByRole("button", { name: "Reset", exact: true }).click();
await expect(name).toHaveValue("Cloud");
await search.fill("Commit Template");
await dialog.getByRole("treeitem", { name: "Commit Template", exact: true }).click();
await search.clear();
await expect(types.first()).toHaveValue("docs");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
});
+76 -1
View File
@@ -149,7 +149,7 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `settings` | `schema` (JSON file) or `pages` (API 1.3), `title?` — see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
@@ -1251,6 +1251,81 @@ Settings › Plugins (searchable; projects can override values) and the plugin
reads values with `host::setting*`; a stored value of the wrong type falls
back to the default.
#### Multiple pages, groups, lists and tables (API 1.3)
Require `engines.openidea = "^1.3"` when using pages, groups, arrays or actions.
The single `schema` form above remains supported; do not combine it with `pages`.
```toml
[contributes.settings]
title = "Model Profiles"
[[contributes.settings.pages]]
id = "templates"
title = "Commit Template"
schema = "templates.json"
[[contributes.settings.pages]]
id = "llm"
title = "LLM Settings"
schema = "llm.json"
[[contributes.settings.pages.actions]]
title = "Test"
command = "test" # must also be declared under contributes.commands
```
Each page appears under the plugin's Settings node. Page titles, property
labels, descriptions, groups, table columns, enum options and action titles
are searchable. Page ids must be unique, contain no dots, and follow the
contribution id rules. Setting keys are unique across the entire plugin;
`host::setting(key)` still returns JSON, including arrays.
Properties with `"group": "Provider"` share a heading in schema order.
An `array` with `"items": {"type": "string"}` provides add/remove/reorder.
For a model-profile table, use:
```json
{
"properties": {
"profiles": {
"type": "array", "title": "Model profiles", "group": "Provider",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"provider": { "type": "string", "title": "Provider", "enum": ["local", "openai"], "default": "local" },
"baseUrl": { "type": "string", "title": "Base URL" },
"model": { "type": "string", "title": "Model" }
}
}
}
}
}
```
Table cells support scalar types, including enums. Rows edit inline; new rows
use each column's default. Nested collections are unsupported. Arrays default
to `[]` and contain at most 1,000 entries. A plugin can declare at most 32 pages,
200 settings across pages, and 32 actions per page. Field and table-column order
follows the schema; duplicate property keys are rejected, including table columns.
Edits use the normal Apply/OK/Cancel flow. Reset discards only the current page’s
edits, including when several pages edit one plugin. Invalid collection edits
are rejected without replacing existing rows. **Running a page action applies all
pending settings first**, then invokes its declared command using the page’s
Global or This project settings layer. The command retains its project context. A failed save
prevents the command; a command error appears beside the button. Successful
commands report through plugin notifications. Cancel after an action does not
undo settings that action already applied. Secrets remain under
`[[contributes.secrets]]`; never store API keys in a profile table. Custom web
settings pages and conditional fields are unsupported; use a plugin view for
other custom UI.
See the [complete two-page fixture](../crates/ide-plugin/tests/fixtures/settings-pages)
for a model-profile table and working Test action, and the
[Rust SDK settings example](../sdk/rust/openidea-plugin/README.md#multi-page-settings-api-13).
### Remote projects
Plugin code runs where the project is. When a window opens a remote project,
+17
View File
@@ -64,3 +64,20 @@ tab with custom toolbar actions (`content-tab-action` / `content-tab-reply`),
native diff viewer integration, and full `EditorDocumentLifecycle` support. See
the [protocol and lifecycle](../../../plugins/README.md#plugin-backed-content-editor-tabs-api-13)
and the [built fixture](../../../crates/ide-plugin/tests/fixtures/web-content).
## Multi-page settings (API 1.3)
Declare `[[contributes.settings.pages]]` with `id`, `title`, and `schema` and
require `engines.openidea = "^1.3"`. The IDE supplies searchable pages, grouped
fields, string lists and object tables. Values stay plugin-wide; read a table
with `host::setting("profiles")` and deserialize its JSON array.
A page action declares `title` and `command`. Pending settings are applied
before the command runs; return `Error::Invalid` / `Error::Failed` for an inline
failure and call `host::notify` on success. Keep API keys in declared secrets.
The [complete example](../../../crates/ide-plugin/tests/fixtures/settings-pages)
contains two pages, a model-profile table and a Test button, including the
manifest, schemas and Rust command implementation. See the
[settings contract](../../../plugins/README.md#multiple-pages-groups-lists-and-tables-api-13)
for supported field types, limits and Apply/Cancel behavior.
+4
View File
@@ -12,6 +12,7 @@ use ide_host::plugins::{
PluginEditor, PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent,
};
use ide_plugin::{Invocation, Limits};
use ide_workspace::settings::SettingsScope;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -327,6 +328,7 @@ pub async fn plugin_reload(state: State<'_, AppState>, id: String) -> IdeResult<
/// Runs a plugin command in the context of this window's project and focused editor.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn plugin_run_command(
window: Window,
state: State<'_, AppState>,
@@ -335,6 +337,7 @@ pub async fn plugin_run_command(
arg: Option<String>,
editor: Option<PluginEditor>,
paths: Option<Vec<PathBuf>>,
settings_scope: Option<SettingsScope>,
) -> IdeResult<Option<String>> {
let host = state.plugins.clone();
let project = state.project(window.label()).ok();
@@ -343,6 +346,7 @@ pub async fn plugin_run_command(
workspace_root: project.as_ref().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
selected_paths: paths.unwrap_or_default(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
+1 -1
View File
@@ -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",
);
});
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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({});
});
});
+12 -1
View File
@@ -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),
}),
+3
View File
@@ -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),
+62 -7
View File
@@ -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");
},
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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.": "该值超出了此设置的限制。"
}
+4
View File
@@ -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,