From 905a672bad48960b4122298990b474b43efc27db Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:38:42 +0100 Subject: [PATCH] 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 <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/Cargo.toml | 2 +- crates/ide-host/src/plugins/index_ops.rs | 1 + crates/ide-host/src/plugins/mod.rs | 1 + crates/ide-host/src/plugins/remote_host.rs | 3 + crates/ide-host/src/plugins/ui_points.rs | 267 +++++++++++++- crates/ide-host/tests/distribution.rs | 8 +- crates/ide-host/tests/plugin_indexing.rs | 1 + crates/ide-host/tests/plugins.rs | 27 +- crates/ide-host/tests/remote_plugins.rs | 25 +- crates/ide-plugin/src/types.rs | 2 + crates/ide-plugin/src/wit_host.rs | 2 +- .../tests/fixtures/settings-pages/Cargo.lock | 337 +++++++++++++++++ .../tests/fixtures/settings-pages/Cargo.toml | 21 ++ .../tests/fixtures/settings-pages/README.md | 17 + .../fixtures/settings-pages/dist/plugin.wasm | Bin 0 -> 168490 bytes .../settings-pages/dist/plugin.wasm.sources | 1 + .../tests/fixtures/settings-pages/llm.json | 19 + .../tests/fixtures/settings-pages/plugin.toml | 27 ++ .../tests/fixtures/settings-pages/src/lib.rs | 24 ++ .../fixtures/settings-pages/templates.json | 12 + e2e/plugin-settings.spec.ts | 112 ++++++ plugins/README.md | 77 +++- sdk/rust/openidea-plugin/README.md | 17 + src-tauri/src/commands/plugin.rs | 4 + src/bindings.ts | 2 +- .../plugins/PluginSettings.module.css | 86 +++++ src/features/plugins/PluginsPage.module.css | 13 - .../plugins/__tests__/settings.test.tsx | 280 ++++++++++++++ src/features/plugins/backend.ts | 5 +- src/features/plugins/pluginText.test.ts | 47 +++ src/features/plugins/pluginText.ts | 5 +- src/features/plugins/settings.tsx | 346 ++++++++++++++++++ src/features/plugins/uiPoints.tsx | 119 +----- .../settings/__tests__/staging.test.ts | 35 ++ src/features/settings/components/context.ts | 13 +- src/features/settings/model/schema.ts | 3 + src/features/settings/model/staging.ts | 69 +++- src/features/tools/__tests__/settingsForm.tsx | 4 + src/platform/locales/text/en.json | 5 +- src/platform/locales/text/zh.json | 5 +- src/workbench/contributions.ts | 4 + 41 files changed, 1887 insertions(+), 161 deletions(-) create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/README.md create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/llm.json create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/settings-pages/templates.json create mode 100644 e2e/plugin-settings.spec.ts create mode 100644 src/features/plugins/PluginSettings.module.css create mode 100644 src/features/plugins/__tests__/settings.test.tsx create mode 100644 src/features/plugins/settings.tsx diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index ada0bcd62..2f17cf621 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -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 diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index d4037c9f9..ef11aac62 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -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. diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 27aa05e4b..be00ee3c1 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1811,6 +1811,7 @@ impl PluginHost { client: client.clone(), editor: None, selected_paths: Vec::new(), + global_settings: false, }; let kinds: BTreeSet = queue.events.iter().map(WorkbenchEvent::kind).collect(); let filesystem = kinds.contains(&EventKind::FilesChanged); diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 31d1462ef..be1a6b117 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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 = c.arg("arg")?; let editor: Option = c.arg("editor")?; let paths: Option> = c.arg("paths")?; + let settings_scope: Option = 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 } diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index b2bf25f7e..522602631 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -283,12 +283,60 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result, + schema: Option, + #[serde(default)] + pages: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SettingsPage { + id: String, + title: String, schema: PathBuf, + #[serde(default)] + actions: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SettingsAction { + title: String, + command: String, } #[derive(Debug, Deserialize)] struct SchemaFile { - properties: serde_json::Map, + #[serde(deserialize_with = "ordered_properties")] + properties: Vec<(String, Box)>, +} + +// Preserve schema field order locally, without changing serde_json maps throughout the host. +fn ordered_properties<'de, D>(deserializer: D) -> Result)>, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Properties; + impl<'de> serde::de::Visitor<'de> for Properties { + type Value = Vec<(String, Box)>; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an object of setting schemas") + } + + fn visit_map>(self, mut map: M) -> Result { + let mut fields = Vec::new(); + let mut keys = BTreeSet::new(); + while let Some((key, value)) = map.next_entry::>()? { + 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>, #[serde(default)] pub multiline: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub properties: Vec, } 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 { +fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result { + setting_at(key, raw, 0) +} + +fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result { 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, + } + 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::(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::>()?; + 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 { 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, String> { pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result { 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, 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::>(), + ["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::>(), ["z", "a"]); + let ordinary: Value = serde_json::from_str(raw).unwrap(); + assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::>(), ["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(); diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index ff23881f5..1af8a1068 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -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 { diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs index 182bae672..22cd83849 100644 --- a/crates/ide-host/tests/plugin_indexing.rs +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -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(); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index b885e6c85..7af03ed04 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -29,6 +29,7 @@ struct TestWorkspace { diagnostics: Mutex>>, /// plugin → its settings as the user set them. settings: Mutex>>, + global_settings: Mutex>>, } 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 { - self.settings.lock().get(plugin).cloned().unwrap_or_default() + fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map { + 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). diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index 0180ced79..3416eef09 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -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(); } diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 23e010aeb..81c4f0952 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -27,6 +27,8 @@ pub struct EditorContext { pub struct Invocation { pub client: Option, pub workspace_root: Option, + /// Settings actions can read the global layer while retaining their project context. + pub global_settings: bool, pub editor: Option, /// Files and folders the command was run on. pub selected_paths: Vec, diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index eaa4e6061..8ef5430b3 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -1177,7 +1177,7 @@ impl views::Host for StoreData { impl settings::Host for StoreData { fn get(&mut self, key: String) -> wasmtime::Result> { - 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))) } } diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.lock b/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.lock new file mode 100644 index 000000000..023a65e69 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.toml b/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.toml new file mode 100644 index 000000000..44dbd2674 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/README.md b/crates/ide-plugin/tests/fixtures/settings-pages/README.md new file mode 100644 index 000000000..446acdb32 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/README.md @@ -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. diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..6101ed6cc815652f1bef7b65493786e4eec7365d GIT binary patch literal 168490 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
n*SmF~2XPX`0bmPBA#pHj=qt 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 Qdr-2bwXur+W4FEk1GaYS{Qv*} literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources new file mode 100644 index 000000000..55610e074 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources @@ -0,0 +1 @@ +42848c3b8b0aeaee3e861c8b76b85d13d28374572fc6e17e059b2d2015fbac17 diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/llm.json b/crates/ide-plugin/tests/fixtures/settings-pages/llm.json new file mode 100644 index 000000000..56a57bafd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/llm.json @@ -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 } + } + } + } + } +} diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/plugin.toml b/crates/ide-plugin/tests/fixtures/settings-pages/plugin.toml new file mode 100644 index 000000000..891322022 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/src/lib.rs b/crates/ide-plugin/tests/fixtures/settings-pages/src/lib.rs new file mode 100644 index 000000000..11629240a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/src/lib.rs @@ -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, 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); diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/templates.json b/crates/ide-plugin/tests/fixtures/settings-pages/templates.json new file mode 100644 index 000000000..3cb1c14d4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/settings-pages/templates.json @@ -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"] + } + } +} diff --git a/e2e/plugin-settings.spec.ts b/e2e/plugin-settings.spec.ts new file mode 100644 index 000000000..e11ecd250 --- /dev/null +++ b/e2e/plugin-settings.spec.ts @@ -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 & { + title?: string; + default?: unknown; + enum?: string[]; + items?: SchemaField; + properties?: Record; +}; + +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 }) + .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(); +}); diff --git a/plugins/README.md b/plugins/README.md index b0a6dd099..de6bb06ed 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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, diff --git a/sdk/rust/openidea-plugin/README.md b/sdk/rust/openidea-plugin/README.md index 1571d1071..28b401337 100644 --- a/sdk/rust/openidea-plugin/README.md +++ b/sdk/rust/openidea-plugin/README.md @@ -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. diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 39d22a488..3051b72c4 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -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, editor: Option, paths: Option>, + settings_scope: Option, ) -> IdeResult> { 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 } diff --git a/src/bindings.ts b/src/bindings.ts index ccd7236ff..f6394480c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1075,7 +1075,7 @@ export const commands = { path: string, language: string, selections: Range[], -} | null, paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths })), +} | null, paths: string[] | null, settingsScope: "global" | "project" | null) => typedError(__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("plugin_post_events", { events }), /** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */ diff --git a/src/features/plugins/PluginSettings.module.css b/src/features/plugins/PluginSettings.module.css new file mode 100644 index 000000000..8e8643773 --- /dev/null +++ b/src/features/plugins/PluginSettings.module.css @@ -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; +} diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index dff2a73ef..b61a4f781 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -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; diff --git a/src/features/plugins/__tests__/settings.test.tsx b/src/features/plugins/__tests__/settings.test.tsx new file mode 100644 index 000000000..254d38eb0 --- /dev/null +++ b/src/features/plugins/__tests__/settings.test.tsx @@ -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; + 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 ( + + + + ); +} + +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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + view.rerender(); + 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(); + 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(); + 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( 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 ( + + + + ); + } + render(); + 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(); + 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(); + 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(); + fireEvent.change(screen.getAllByRole("textbox")[0]!, { target: { value: "docs" } }); + view.rerender(); + 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>)[ + "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(); + 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(); + 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", + ); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index d443aae17..4612feec1 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -62,6 +62,7 @@ export interface PluginsBackend { arg: string | null, editor: PluginEditor | null, paths: string[], + settingsScope?: "global" | "project", ): Promise; /** Workbench events plugins subscribed to (see `eventKinds`). */ postEvents(events: WorkbenchEvent[]): Promise; @@ -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); }, diff --git a/src/features/plugins/pluginText.test.ts b/src/features/plugins/pluginText.test.ts index 2d5c3fdc1..3eaaefa08 100644 --- a/src/features/plugins/pluginText.test.ts +++ b/src/features/plugins/pluginText.test.ts @@ -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({ diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts index e24332630..b0ee6711c 100644 --- a/src/features/plugins/pluginText.ts +++ b/src/features/plugins/pluginText.ts @@ -91,7 +91,10 @@ function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate const out: Record = { ...(decl as Record) }; 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; } diff --git a/src/features/plugins/settings.tsx b/src/features/plugins/settings.tsx new file mode 100644 index 000000000..4cfd87da6 --- /dev/null +++ b/src/features/plugins/settings.tsx @@ -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>; + +/** `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 ; + if (s.type === "boolean") + return ; + if (s.options) + return ( +