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 000000000..6101ed6cc Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm differ 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 ( +