mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(plugins): add declarative multi-page settings (#2074)
Closes #1720 Plugins can declare searchable multi-page settings (API 1.3): pages under the plugin in Settings, groups, string lists, editable tables and command buttons, while single-schema plugins keep working. A command button applies pending edits first and runs against the selected Global/Project layer (also remotely); Reset keeps other pages' edits, and invalid table/list edits never replace saved rows. Field and page order come from the schema without changing serde_json's map ordering elsewhere in the app. Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
1 parent
99102d8a40
commit
905a672bad
41 files changed
+1887
-161
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1811,6 +1811,7 @@ impl PluginHost {
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
|
||||
@@ -31,6 +31,7 @@ use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{MANIFEST_FILE, ledger};
|
||||
use ide_jsonrpc::Peer;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -376,11 +377,13 @@ impl RemotePlugins {
|
||||
let arg: Option<String> = c.arg("arg")?;
|
||||
let editor: Option<PluginEditor> = c.arg("editor")?;
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
|
||||
let invocation = Invocation {
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
selected_paths: paths.unwrap_or_default(),
|
||||
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
}
|
||||
|
||||
@@ -283,12 +283,60 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
|
||||
struct SettingsDecl {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
schema: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pages: Vec<SettingsPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SettingsPage {
|
||||
id: String,
|
||||
title: String,
|
||||
schema: PathBuf,
|
||||
#[serde(default)]
|
||||
actions: Vec<SettingsAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SettingsAction {
|
||||
title: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SchemaFile {
|
||||
properties: serde_json::Map<String, Value>,
|
||||
#[serde(deserialize_with = "ordered_properties")]
|
||||
properties: Vec<(String, Box<serde_json::value::RawValue>)>,
|
||||
}
|
||||
|
||||
// Preserve schema field order locally, without changing serde_json maps throughout the host.
|
||||
fn ordered_properties<'de, D>(deserializer: D) -> Result<Vec<(String, Box<serde_json::value::RawValue>)>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Properties;
|
||||
impl<'de> serde::de::Visitor<'de> for Properties {
|
||||
type Value = Vec<(String, Box<serde_json::value::RawValue>)>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an object of setting schemas")
|
||||
}
|
||||
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
|
||||
let mut fields = Vec::new();
|
||||
let mut keys = BTreeSet::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, Box<serde_json::value::RawValue>>()? {
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(serde::de::Error::custom(format!("duplicate setting key {key:?}")));
|
||||
}
|
||||
fields.push((key, value));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Properties)
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
@@ -296,7 +344,7 @@ struct SchemaFile {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingSchema {
|
||||
pub key: String,
|
||||
/// `boolean`, `string`, `number` or `integer`.
|
||||
/// A scalar, top-level `array`, or `object` table row.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
@@ -307,6 +355,12 @@ pub struct SettingSchema {
|
||||
pub options: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<SettingSchema>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub properties: Vec<SettingSchema>,
|
||||
}
|
||||
|
||||
impl SettingSchema {
|
||||
@@ -316,6 +370,13 @@ impl SettingSchema {
|
||||
"boolean" => v.is_boolean(),
|
||||
"integer" => v.is_i64() || v.is_u64(),
|
||||
"number" => v.is_number(),
|
||||
"array" => v.as_array().is_some_and(|a| {
|
||||
a.len() <= 1000 && self.items.as_ref().is_some_and(|item| a.iter().all(|v| item.accepts(v)))
|
||||
}),
|
||||
"object" => v.as_object().is_some_and(|o| {
|
||||
o.len() == self.properties.len()
|
||||
&& self.properties.iter().all(|p| o.get(&p.key).is_some_and(|v| p.accepts(v)))
|
||||
}),
|
||||
_ => v
|
||||
.as_str()
|
||||
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
|
||||
@@ -323,17 +384,47 @@ impl SettingSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result<SettingSchema, String> {
|
||||
setting_at(key, raw, 0)
|
||||
}
|
||||
|
||||
fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result<SettingSchema, String> {
|
||||
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
|
||||
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
|
||||
}
|
||||
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
let value: Value = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
|
||||
let o = value.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let mut items = None;
|
||||
let mut properties = Vec::new();
|
||||
let default = match kind {
|
||||
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
|
||||
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
|
||||
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
|
||||
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
|
||||
"array" if depth == 0 => {
|
||||
#[derive(Deserialize)]
|
||||
struct ArraySchema {
|
||||
items: Box<serde_json::value::RawValue>,
|
||||
}
|
||||
let array: ArraySchema = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
|
||||
let item = setting_at("item", &array.items, 1)?;
|
||||
if item.kind != "string" && item.kind != "object" {
|
||||
return Err(format!("setting {key:?}: items must be strings or objects"));
|
||||
}
|
||||
items = Some(Box::new(item));
|
||||
o.get("default").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
|
||||
}
|
||||
"object" if depth == 1 => {
|
||||
let fields = serde_json::from_str::<SchemaFile>(raw.get()).map_err(|e| e.to_string())?.properties;
|
||||
if fields.is_empty() || fields.len() > MAX_SETTINGS {
|
||||
return Err(format!("setting {key:?}: table needs 1–{MAX_SETTINGS} columns"));
|
||||
}
|
||||
properties = fields.iter().map(|(k, v)| setting_at(k, v, 2)).collect::<Result<_, _>>()?;
|
||||
o.get("default").cloned().unwrap_or_else(|| {
|
||||
Value::Object(properties.iter().map(|p| (p.key.clone(), p.default.clone())).collect())
|
||||
})
|
||||
}
|
||||
_ => return Err(format!("setting {key:?}: unsupported setting type {kind:?}")),
|
||||
};
|
||||
let options = match o.get("enum") {
|
||||
None => None,
|
||||
@@ -359,6 +450,9 @@ fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
default,
|
||||
options,
|
||||
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
|
||||
group: text("group", MAX_TITLE)?,
|
||||
items,
|
||||
properties,
|
||||
};
|
||||
if !s.accepts(&s.default) {
|
||||
return Err(format!("setting {key:?}: the default does not match its type"));
|
||||
@@ -377,13 +471,68 @@ pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
|
||||
|
||||
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: SettingsDecl = decode(SETTINGS, decl)?;
|
||||
let text = pkg.read_text(&d.schema)?;
|
||||
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
|
||||
if d.schema.is_some() == !d.pages.is_empty() {
|
||||
return Err("settings: use either `schema` or nonempty `pages`".into());
|
||||
}
|
||||
if d.pages.len() > 32 {
|
||||
return Err("settings: at most 32 pages".into());
|
||||
}
|
||||
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
|
||||
title_ok(&title)?;
|
||||
let mut decl =
|
||||
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
|
||||
decl.files.push(d.schema);
|
||||
let mut files = Vec::new();
|
||||
let mut keys = BTreeSet::new();
|
||||
let mut load = |path: &Path| -> Result<Vec<SettingSchema>, String> {
|
||||
let properties =
|
||||
parse_settings_schema(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
for p in &properties {
|
||||
if !keys.insert(p.key.clone()) {
|
||||
return Err(format!("settings: duplicate key {:?} across pages", p.key));
|
||||
}
|
||||
if p.group.is_some() || p.kind == "array" {
|
||||
pkg.manifest.needs_api("settings: groups / arrays", 3)?;
|
||||
}
|
||||
}
|
||||
if keys.len() > MAX_SETTINGS {
|
||||
return Err(format!("more than {MAX_SETTINGS} settings"));
|
||||
}
|
||||
files.push(path.to_path_buf());
|
||||
Ok(properties)
|
||||
};
|
||||
let mut properties = Vec::new();
|
||||
let mut pages = Vec::new();
|
||||
if let Some(path) = d.schema {
|
||||
properties = load(&path)?;
|
||||
} else {
|
||||
pkg.manifest.needs_api("contributes.settings.pages", 3)?;
|
||||
let mut ids = BTreeSet::new();
|
||||
for p in d.pages {
|
||||
validate_item_id(&p.id, "settings page id")?;
|
||||
if p.id.contains('.') || !ids.insert(p.id.clone()) {
|
||||
return Err(format!("settings: page id {:?} must be unique and contain no dots", p.id));
|
||||
}
|
||||
title_ok(&p.title)?;
|
||||
if p.actions.len() > 32 {
|
||||
return Err("settings: at most 32 actions per page".into());
|
||||
}
|
||||
for a in &p.actions {
|
||||
title_ok(&a.title)?;
|
||||
declared_command(pkg, SETTINGS, &a.command)?;
|
||||
}
|
||||
let fields = load(&p.schema)?;
|
||||
properties.extend(fields.clone());
|
||||
pages.push(serde_json::json!({
|
||||
"id": p.id, "title": p.title, "properties": fields, "actions": p.actions,
|
||||
}));
|
||||
}
|
||||
}
|
||||
let mut decl = UiDecl::new(
|
||||
"settings",
|
||||
title.clone(),
|
||||
serde_json::json!({
|
||||
"title": title, "properties": properties, "pages": pages,
|
||||
}),
|
||||
);
|
||||
decl.files = files;
|
||||
Ok(decl)
|
||||
}
|
||||
|
||||
@@ -810,6 +959,104 @@ mod tests {
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_validate_every_cell_and_keep_schema_order() {
|
||||
let schema =
|
||||
parse_settings_schema(include_str!("../../../ide-plugin/tests/fixtures/settings-pages/llm.json")).unwrap();
|
||||
let profiles = &schema[0];
|
||||
let item = profiles.items.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
item.properties.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(),
|
||||
["name", "provider", "baseUrl", "model", "enabled", "temperature", "limit"]
|
||||
);
|
||||
assert!(profiles.accepts(&serde_json::json!([item.default])));
|
||||
for (field, value) in [
|
||||
("provider", Value::from("unknown")),
|
||||
("enabled", Value::from("true")),
|
||||
("limit", Value::from(1.5)),
|
||||
("model", Value::Null),
|
||||
] {
|
||||
let mut row = item.default.clone();
|
||||
row[field] = value;
|
||||
assert!(!profiles.accepts(&serde_json::json!([row])), "{field}");
|
||||
}
|
||||
assert!(!profiles.accepts(&serde_json::json!([{}])));
|
||||
for items in [
|
||||
r#"{"type":"number"}"#,
|
||||
r#"{"type":"array","items":{"type":"string"}}"#,
|
||||
r#"{"type":"object","properties":{}}"#,
|
||||
r#"{"type":"object","properties":{"nested":{"type":"object"}}}"#,
|
||||
] {
|
||||
assert!(
|
||||
parse_settings_schema(&format!(r#"{{"properties":{{"x":{{"type":"array","items":{items}}}}}}}"#))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let lists = parse_settings_schema(
|
||||
r#"{"properties":{"z":{"type":"array","items":{"type":"string"}},"a":{"type":"boolean"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lists[0].key, "z");
|
||||
assert!(lists[0].accepts(&serde_json::json!(["feat", "fix"])));
|
||||
assert!(!lists[0].accepts(&serde_json::json!([1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_order_is_local_and_duplicate_fields_are_rejected() {
|
||||
let raw = r#"{"properties":{"z":{"type":"string"},"a":{"type":"boolean"}}}"#;
|
||||
let fields = parse_settings_schema(raw).unwrap();
|
||||
assert_eq!(fields.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["z", "a"]);
|
||||
let ordinary: Value = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::<Vec<_>>(), ["a", "z"]);
|
||||
for raw in [
|
||||
r#"{"properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}"#,
|
||||
r#"{"properties":{"rows":{"type":"array","items":{"type":"object","properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}}}}"#,
|
||||
] {
|
||||
assert!(parse_settings_schema(raw).unwrap_err().contains("duplicate setting key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_pages_validate_manifest_files_commands_and_api() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/settings-pages");
|
||||
let source = std::fs::read_to_string(root.join("plugin.toml")).unwrap();
|
||||
let check = |source: &str| {
|
||||
let manifest = ide_extension::Manifest::parse(source)?;
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
settings(&Package::new(manifest, root.clone()), decl)
|
||||
};
|
||||
let valid = check(&source).unwrap();
|
||||
assert_eq!(valid.json["pages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(valid.json["properties"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(valid.files.len(), 2);
|
||||
for bad in [
|
||||
source.replace("^1.3", "^1.2"),
|
||||
source.replace(r#"id = "llm""#, r#"id = "templates""#),
|
||||
source.replace(r#"command = "test""#, r#"command = "missing""#),
|
||||
source.replace("llm.json", "missing.json"),
|
||||
source.replace("llm.json", "../outside.json"),
|
||||
source.replace("llm.json", "templates.json"),
|
||||
source.replace(r#"id = "llm""#, r#"id = "llm.child""#),
|
||||
] {
|
||||
assert!(check(&bad).is_err(), "{bad}");
|
||||
}
|
||||
let old = source.split("[contributes.settings]").next().unwrap().replace("^1.3", "^1.0");
|
||||
let old = format!("{old}\n[contributes.settings]\nschema = \"legacy.json\"\n");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("legacy.json"), r#"{"properties":{"enabled":{"type":"boolean"}}}"#).unwrap();
|
||||
let manifest = ide_extension::Manifest::parse(&old).unwrap();
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
assert!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok());
|
||||
for field in [r#"{"type":"string","group":"Provider"}"#, r#"{"type":"array","items":{"type":"string"}}"#] {
|
||||
std::fs::write(dir.path().join("legacy.json"), format!(r#"{{"properties":{{"x":{field}}}}}"#)).unwrap();
|
||||
for (api, valid) in [("^1.0", false), ("^1.2", false), ("^1.3", true)] {
|
||||
let manifest = ide_extension::Manifest::parse(&old.replace("^1.0", api)).unwrap();
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
assert_eq!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,6 +29,7 @@ struct TestWorkspace {
|
||||
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
|
||||
/// plugin → its settings as the user set them.
|
||||
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestWorkspace {
|
||||
@@ -46,6 +47,7 @@ impl TestWorkspace {
|
||||
event_read_gate: Mutex::new(None),
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,8 +127,9 @@ impl PluginWorkspace for TestWorkspace {
|
||||
self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items);
|
||||
Ok(())
|
||||
}
|
||||
fn plugin_settings(&self, plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
self.settings.lock().get(plugin).cloned().unwrap_or_default()
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let layer = if root.is_some() { &self.settings } else { &self.global_settings };
|
||||
layer.lock().get(plugin).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +289,7 @@ impl Fixture {
|
||||
workspace_root: Some(self.project.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2454,6 +2458,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
event_read_gate: Mutex::default(),
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
});
|
||||
f.host.set_workspace(Some(untrusted));
|
||||
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
|
||||
@@ -3058,6 +3063,24 @@ fn official_regex_tester_answers_its_page() {
|
||||
assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_actions_choose_their_layer_without_losing_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("settings-pages"));
|
||||
for (global, name) in [(false, "Project"), (true, "Global")] {
|
||||
let values = serde_json::json!({ "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] });
|
||||
let layer = if global { &f.workspace.global_settings } else { &f.workspace.settings };
|
||||
layer.lock().insert("settings-pages".into(), values.as_object().unwrap().clone());
|
||||
}
|
||||
for (global_settings, name) in [(false, "Project"), (true, "Global"), (false, "Project")] {
|
||||
let inv = Invocation { global_settings, ..f.inv() };
|
||||
let result = f.host.run_command("settings-pages", "test", None, inv).unwrap().unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(result["profiles"][0]["name"], name);
|
||||
assert_eq!(result["hasProject"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree filled from a pinned program's output, and a terminal for the
|
||||
/// activated entry in the invoking window, titled and sized by the plugin's
|
||||
/// settings (the `jobs` fixture).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pub struct EditorContext {
|
||||
pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Settings actions can read the global layer while retaining their project context.
|
||||
pub global_settings: bool,
|
||||
pub editor: Option<EditorContext>,
|
||||
/// Files and folders the command was run on.
|
||||
pub selected_paths: Vec<PathBuf>,
|
||||
|
||||
@@ -1177,7 +1177,7 @@ impl views::Host for StoreData {
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
|
||||
let root = self.invocation.workspace_root.clone();
|
||||
let root = if self.invocation.global_settings { None } else { self.invocation.workspace_root.clone() };
|
||||
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
|
||||
}
|
||||
}
|
||||
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-settings-pages"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-test-settings-pages"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Multi-page settings example (API 1.3)
|
||||
|
||||
Two IDE-rendered pages demonstrate grouped string lists and an editable model
|
||||
profile table. `Test` first applies pending settings, then runs the declared
|
||||
`test` command. With no profiles it returns an inline error; after adding a row
|
||||
it reads the saved JSON array and reports success through a notification.
|
||||
This fixture checks the settings/action flow; it does not contact a provider.
|
||||
|
||||
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/settings-pages`.
|
||||
Install or link this directory with `openidea-plugin link` and open
|
||||
Settings › Plugins › Model Profiles. Edit without Apply and Cancel to discard;
|
||||
Apply or OK saves. Running Test also saves pending edits before the command,
|
||||
so Cancel after Test does not undo those already-applied edits.
|
||||
|
||||
API keys belong in `[[contributes.secrets]]`, never table cells. A real Test
|
||||
connection command can use `host::fetch` with the appropriate declared network
|
||||
capability and secret-backed headers.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
42848c3b8b0aeaee3e861c8b76b85d13d28374572fc6e17e059b2d2015fbac17
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"properties": {
|
||||
"profiles": {
|
||||
"type": "array", "title": "Model profiles", "group": "Provider",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "title": "Name", "default": "Local" },
|
||||
"provider": { "type": "string", "title": "Provider", "enum": ["local", "openai"], "default": "local" },
|
||||
"baseUrl": { "type": "string", "title": "Base URL", "default": "http://localhost:11434" },
|
||||
"model": { "type": "string", "title": "Model", "default": "example-model" },
|
||||
"enabled": { "type": "boolean", "title": "Enabled", "default": true },
|
||||
"temperature": { "type": "number", "title": "Temperature", "default": 0.7 },
|
||||
"limit": { "type": "integer", "title": "Token limit", "default": 2048 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "settings-pages"
|
||||
name = "Model Profiles"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "test"
|
||||
title = "Test model profile"
|
||||
|
||||
[contributes.settings]
|
||||
title = "Model Profiles"
|
||||
|
||||
[[contributes.settings.pages]]
|
||||
id = "templates"
|
||||
title = "Commit Template"
|
||||
schema = "templates.json"
|
||||
|
||||
[[contributes.settings.pages]]
|
||||
id = "llm"
|
||||
title = "LLM Settings"
|
||||
schema = "llm.json"
|
||||
|
||||
[[contributes.settings.pages.actions]]
|
||||
title = "Test"
|
||||
command = "test"
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Settings action example. A real connection test can use host::fetch with a
|
||||
//! declared network capability and a secret-backed Authorization header.
|
||||
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Profiles;
|
||||
|
||||
impl Plugin for Profiles {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "test" {
|
||||
return Err(Error::Invalid("unknown command".into()));
|
||||
}
|
||||
let json = host::setting("profiles").unwrap_or_else(|| "[]".into());
|
||||
let profiles: serde_json::Value = serde_json::from_str(&json).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
let rows = profiles.as_array().ok_or("model profiles must be an array")?;
|
||||
if rows.is_empty() {
|
||||
return Err(Error::Invalid("Add a model profile before testing".into()));
|
||||
}
|
||||
host::notify(NotificationLevel::Info, "Model Profiles", &format!("Read {} saved profile(s)", rows.len()));
|
||||
Ok(Some(serde_json::json!({ "profiles": profiles, "hasProject": ctx.project().is_some() }).to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Profiles);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"properties": {
|
||||
"templates": {
|
||||
"type": "array", "title": "Commit templates", "group": "Templates",
|
||||
"items": { "type": "string" }, "default": ["{type}: {summary}"]
|
||||
},
|
||||
"types": {
|
||||
"type": "array", "title": "Allowed commit types", "group": "Templates",
|
||||
"items": { "type": "string" }, "default": ["feat", "fix", "docs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import type { SettingSchema } from "../src/features/plugins/settings";
|
||||
|
||||
type SchemaField = Omit<SettingSchema, "key" | "title" | "default" | "items" | "properties"> & {
|
||||
title?: string;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
items?: SchemaField;
|
||||
properties?: Record<string, SchemaField>;
|
||||
};
|
||||
|
||||
const fixture = "crates/ide-plugin/tests/fixtures/settings-pages/";
|
||||
const fields = (name: string) =>
|
||||
Object.entries(
|
||||
(JSON.parse(readFileSync(`${fixture}${name}.json`, "utf8")) as { properties: Record<string, SchemaField> })
|
||||
.properties,
|
||||
).map(([key, value]) => {
|
||||
const convert = (key: string, value: SchemaField): SettingSchema => ({
|
||||
...value,
|
||||
key,
|
||||
options: value.enum,
|
||||
title: value.title ?? key,
|
||||
default:
|
||||
value.default ??
|
||||
(value.type === "array"
|
||||
? []
|
||||
: value.type === "object"
|
||||
? Object.fromEntries(Object.entries(value.properties ?? {}).map(([k, v]) => [k, convert(k, v).default]))
|
||||
: value.type === "boolean"
|
||||
? false
|
||||
: value.type === "string"
|
||||
? ""
|
||||
: 0),
|
||||
items: value.items ? convert("item", value.items) : undefined,
|
||||
properties: value.properties ? Object.entries(value.properties ?? {}).map(([k, v]) => convert(k, v)) : undefined,
|
||||
});
|
||||
return convert(key, value);
|
||||
});
|
||||
|
||||
test("plugin settings pages stage edits, apply before actions and discard on Cancel", async ({ workbench: page }) => {
|
||||
const templates = fields("templates");
|
||||
const llm = fields("llm");
|
||||
await page.evaluate(
|
||||
async ({ templates, llm }) => {
|
||||
const module = "/src/features/plugins/projection.ts";
|
||||
const { uiPoint } = await import(/* @vite-ignore */ module);
|
||||
const decl = {
|
||||
title: "Model Profiles",
|
||||
properties: [...templates, ...llm],
|
||||
pages: [
|
||||
{ id: "templates", title: "Commit Template", properties: templates },
|
||||
{ id: "llm", title: "LLM Settings", properties: llm, actions: [{ title: "Test", command: "test" }] },
|
||||
],
|
||||
};
|
||||
uiPoint("settings").project({ pluginId: "settings-pages", point: "settings", key: "settings", decl }, {});
|
||||
{
|
||||
const backendModule = "/src/features/plugins/backend.ts";
|
||||
const { pluginsBackend } = await import(/* @vite-ignore */ backendModule);
|
||||
pluginsBackend().runCommand = async () => {
|
||||
throw new Error("Example provider unavailable");
|
||||
};
|
||||
}
|
||||
},
|
||||
{ templates, llm },
|
||||
);
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
const search = dialog.getByRole("textbox", { name: "Search settings" });
|
||||
await search.fill("LLM Settings");
|
||||
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
|
||||
await search.clear();
|
||||
await dialog.getByRole("treeitem", { name: "Plugins", exact: true }).click();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await dialog.getByRole("treeitem", { name: "Model Profiles", exact: true }).click();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
|
||||
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||
const name = dialog.getByRole("textbox", { name: "Model profiles: Name 1", exact: true });
|
||||
await name.fill("Cloud");
|
||||
if (process.env.SETTINGS_EVIDENCE)
|
||||
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-1.png`, animations: "disabled" });
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toHaveText("Example provider unavailable");
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
if (process.env.SETTINGS_EVIDENCE)
|
||||
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-2.png`, animations: "disabled" });
|
||||
await name.fill("Discard me");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(name).toHaveValue("Cloud");
|
||||
await search.fill("Commit Template");
|
||||
await dialog.getByRole("treeitem", { name: "Commit Template", exact: true }).click();
|
||||
await search.clear();
|
||||
await expect(dialog.getByRole("table", { name: "Allowed commit types" })).toBeVisible();
|
||||
if (process.env.SETTINGS_EVIDENCE)
|
||||
await page.screenshot({ path: `${process.env.SETTINGS_EVIDENCE}/after-3.png`, animations: "disabled" });
|
||||
const types = dialog.getByRole("table", { name: "Allowed commit types" }).getByRole("textbox");
|
||||
await types.first().fill("docs");
|
||||
await search.fill("LLM Settings");
|
||||
await dialog.getByRole("treeitem", { name: "LLM Settings", exact: true }).click();
|
||||
await search.clear();
|
||||
await name.fill("Reset only this page");
|
||||
await dialog.getByRole("button", { name: "Reset", exact: true }).click();
|
||||
await expect(name).toHaveValue("Cloud");
|
||||
await search.fill("Commit Template");
|
||||
await dialog.getByRole("treeitem", { name: "Commit Template", exact: true }).click();
|
||||
await search.clear();
|
||||
await expect(types.first()).toHaveValue("docs");
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
});
|
||||
+76
-1
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -12,6 +12,7 @@ use ide_host::plugins::{
|
||||
PluginEditor, PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent,
|
||||
};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -327,6 +328,7 @@ pub async fn plugin_reload(state: State<'_, AppState>, id: String) -> IdeResult<
|
||||
/// Runs a plugin command in the context of this window's project and focused editor.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn plugin_run_command(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
@@ -335,6 +337,7 @@ pub async fn plugin_run_command(
|
||||
arg: Option<String>,
|
||||
editor: Option<PluginEditor>,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
settings_scope: Option<SettingsScope>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let host = state.plugins.clone();
|
||||
let project = state.project(window.label()).ok();
|
||||
@@ -343,6 +346,7 @@ pub async fn plugin_run_command(
|
||||
workspace_root: project.as_ref().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
selected_paths: paths.unwrap_or_default(),
|
||||
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
}
|
||||
|
||||
+1
-1
@@ -1075,7 +1075,7 @@ export const commands = {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null, paths: string[] | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths })),
|
||||
} | null, paths: string[] | null, settingsScope: "global" | "project" | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths, settingsScope })),
|
||||
/** Workbench events from this window (only the kinds of `plugin_event_kinds`). */
|
||||
pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE<void>("plugin_post_events", { events }),
|
||||
/** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
.page,
|
||||
.group,
|
||||
.field,
|
||||
.array {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.group h3 {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.scroll th,
|
||||
.scroll td {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.scroll input {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.multiline {
|
||||
min-height: 100px;
|
||||
padding: var(--space-2);
|
||||
color: var(--fg);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.scroll th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll th:last-child,
|
||||
.scroll td:last-child {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
background: var(--bg-dialog, var(--bg-elev));
|
||||
}
|
||||
|
||||
.scroll td:last-child .buttons {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.scalars {
|
||||
max-width: 560px;
|
||||
}
|
||||
@@ -351,19 +351,6 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settingsPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.marketItem {
|
||||
list-style: none;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import { SettingsFormContext, useSettingsPages } from "@/workbench/contributions";
|
||||
import { useSettingsFormFor } from "@/features/settings/components/context";
|
||||
import {
|
||||
closeSettings,
|
||||
openSettings,
|
||||
resetPage,
|
||||
stagedValue,
|
||||
useSettingsDialog,
|
||||
} from "@/features/settings/model/staging";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
|
||||
import { projectSettings, type SettingsDecl } from "../settings";
|
||||
|
||||
const profile = {
|
||||
key: "profiles",
|
||||
title: "Model profiles",
|
||||
type: "array",
|
||||
group: "Provider",
|
||||
default: [],
|
||||
items: {
|
||||
key: "item",
|
||||
title: "item",
|
||||
type: "object",
|
||||
default: { name: "Local", enabled: true, limit: 5 },
|
||||
properties: [
|
||||
{ key: "name", title: "Name", type: "string", default: "Local" },
|
||||
{ key: "enabled", title: "Enabled", type: "boolean", default: true },
|
||||
{ key: "limit", title: "Token limit", type: "integer", default: 5 },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
const declaration: SettingsDecl = {
|
||||
title: "Helper",
|
||||
properties: [],
|
||||
pages: [
|
||||
{
|
||||
id: "templates",
|
||||
title: "Commit Template",
|
||||
properties: [
|
||||
{
|
||||
key: "types",
|
||||
title: "Allowed types",
|
||||
type: "array",
|
||||
default: ["feat", "fix"],
|
||||
items: { key: "item", title: "item", type: "string", default: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "llm",
|
||||
title: "LLM Settings",
|
||||
properties: [JSON.parse(JSON.stringify(profile))],
|
||||
actions: [{ title: "Test", command: "test" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let dispose: () => void;
|
||||
const run = vi.fn(async () => null);
|
||||
beforeEach(() => {
|
||||
useProject.setState({ settings: null, project: null });
|
||||
openSettings();
|
||||
run.mockReset().mockResolvedValue(null);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend([]), runCommand: run });
|
||||
dispose = projectSettings("example.helper", declaration);
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
closeSettings();
|
||||
setPluginsBackend(null);
|
||||
});
|
||||
|
||||
function Harness({
|
||||
page,
|
||||
apply,
|
||||
scope = "global",
|
||||
}: {
|
||||
page: string;
|
||||
apply?: () => Promise<boolean>;
|
||||
scope?: "global" | "project";
|
||||
}) {
|
||||
const id = `plugins.example-helper.settings:${page}`;
|
||||
const form = useSettingsFormFor(id, scope);
|
||||
const Component = useSettingsPages.getState().pages.get(id)!.component as ComponentType;
|
||||
return (
|
||||
<SettingsFormContext.Provider value={apply ? { ...form, apply } : form}>
|
||||
<Component />
|
||||
</SettingsFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
it("registers searchable child pages and removes all of them on unload", () => {
|
||||
const pages = useSettingsPages.getState().pages;
|
||||
expect(pages.get("plugins.example-helper")!.component).toBeUndefined();
|
||||
const page = pages.get("plugins.example-helper.settings:llm")!;
|
||||
expect(page.title).toBe("LLM Settings");
|
||||
expect(page.keywords).toEqual(expect.arrayContaining(["Provider", "Name", "Token limit", "Test"]));
|
||||
dispose();
|
||||
expect([...useSettingsPages.getState().pages.keys()].some((k) => k.startsWith("plugins.example-helper"))).toBe(false);
|
||||
});
|
||||
|
||||
it("stages table edits, keeps changes between pages and discards them on Cancel", () => {
|
||||
const view = render(<Harness page="llm" />);
|
||||
expect(screen.getByRole("heading", { name: "Provider" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Model profiles: Name 1" }), { target: { value: "Cloud" } });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Model profiles: Enabled 1" }));
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Model profiles: Token limit 1" }), {
|
||||
target: { value: "7.9" },
|
||||
});
|
||||
expect(useProject.getState().settings).toBeNull();
|
||||
view.rerender(<Harness page="templates" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
view.rerender(<Harness page="llm" />);
|
||||
expect(screen.getByRole("textbox", { name: "Model profiles: Name 1" })).toHaveValue("Cloud");
|
||||
const pending = stagedValue(useSettingsDialog.getState(), "global", "plugins");
|
||||
expect(pending).toMatchObject({
|
||||
"example.helper": { profiles: [{ name: "Cloud", enabled: false, limit: 7 }], types: ["feat", "fix", ""] },
|
||||
});
|
||||
act(() => {
|
||||
closeSettings();
|
||||
openSettings();
|
||||
});
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds, edits, reorders and removes list entries", () => {
|
||||
render(<Harness page="templates" />);
|
||||
const table = within(screen.getByRole("table", { name: "Allowed types" }));
|
||||
fireEvent.click(table.getAllByRole("button", { name: "Move Down" })[0]!);
|
||||
expect(table.getAllByRole("textbox")[0]).toHaveValue("fix");
|
||||
fireEvent.change(table.getAllByRole("textbox")[0]!, { target: { value: "docs" } });
|
||||
fireEvent.click(table.getAllByRole("button", { name: "Move Up" })[1]!);
|
||||
expect(table.getAllByRole("textbox")[0]).toHaveValue("feat");
|
||||
fireEvent.click(table.getAllByRole("button", { name: "Remove" })[0]!);
|
||||
expect(table.getAllByRole("textbox")).toHaveLength(1);
|
||||
expect(table.getByRole("textbox")).toHaveValue("docs");
|
||||
});
|
||||
|
||||
it("applies pending values before a command reads them and shows command errors inline", async () => {
|
||||
run.mockImplementation(async () => {
|
||||
expect(useProject.getState().settings).toMatchObject({
|
||||
plugins: { "example.helper": { profiles: [{ name: "Local" }] } },
|
||||
});
|
||||
throw new Error("Connection refused");
|
||||
});
|
||||
render(<Harness page="llm" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test" }));
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Connection refused"));
|
||||
expect(run).toHaveBeenCalledWith("example.helper", "test", null, null, [], "global");
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
act(() => closeSettings());
|
||||
expect(useProject.getState().settings).toMatchObject({
|
||||
plugins: { "example.helper": { profiles: [{ name: "Local" }] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run a command when applying settings fails", async () => {
|
||||
render(<Harness page="llm" apply={async () => false} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test" }));
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Could not apply settings."));
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the legacy single-schema page", () => {
|
||||
const remove = projectSettings("legacy", {
|
||||
title: "Legacy",
|
||||
properties: [{ key: "enabled", title: "Enabled", type: "boolean", default: true }],
|
||||
});
|
||||
const Component = useSettingsPages.getState().pages.get("plugins.legacy")!.component!;
|
||||
function Legacy() {
|
||||
const form = useSettingsFormFor("plugins.legacy", "global");
|
||||
return (
|
||||
<SettingsFormContext.Provider value={form}>
|
||||
<Component />
|
||||
</SettingsFormContext.Provider>
|
||||
);
|
||||
}
|
||||
render(<Legacy />);
|
||||
expect(screen.getByRole("checkbox", { name: "Enabled" })).toBeChecked();
|
||||
remove();
|
||||
});
|
||||
|
||||
it("loads saved plugin settings into the dialog, including dotted plugin ids and keys", () => {
|
||||
useProject.setState({
|
||||
settings: {
|
||||
plugins: { "example.helper": { profiles: [{ name: "Saved", enabled: true, limit: 3 }], "custom.key": ["x"] } },
|
||||
} as never,
|
||||
});
|
||||
openSettings();
|
||||
render(<Harness page="llm" />);
|
||||
expect(screen.getByRole("textbox", { name: "Model profiles: Name 1" })).toHaveValue("Saved");
|
||||
});
|
||||
|
||||
it.each([{ profiles: [null] }, { profiles: [{}] }, { profiles: [1] }, { profiles: "invalid" }])(
|
||||
"falls back from malformed stored tables: %j",
|
||||
({ profiles }) => {
|
||||
useProject.setState({ settings: { plugins: { "example.helper": { profiles } } } as never });
|
||||
openSettings();
|
||||
render(<Harness page="llm" />);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps Reset ownership on each page while merging plugin map writes", () => {
|
||||
const view = render(<Harness page="templates" />);
|
||||
fireEvent.change(screen.getAllByRole("textbox")[0]!, { target: { value: "docs" } });
|
||||
view.rerender(<Harness page="llm" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(new Set(Object.values(useSettingsDialog.getState().staged).map((c) => c.page)).size).toBe(2);
|
||||
act(() => resetPage("plugins.example-helper.settings:templates"));
|
||||
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toMatchObject({
|
||||
"example.helper": { profiles: [{ name: "Local" }] },
|
||||
});
|
||||
expect(
|
||||
(stagedValue(useSettingsDialog.getState(), "global", "plugins") as Record<string, Record<string, unknown>>)[
|
||||
"example.helper"
|
||||
]!.types,
|
||||
).toBeUndefined();
|
||||
act(() => resetPage("plugins.example-helper.settings:llm"));
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["llm", "templates"])("rejects oversized %s edits without discarding other rows", (page) => {
|
||||
render(<Harness page={page} />);
|
||||
if (page === "llm") {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
}
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
const prior = inputs[0]!.getAttribute("value");
|
||||
fireEvent.change(inputs[0]!, { target: { value: "é".repeat(32769) } });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("The value exceeds this setting’s limits.");
|
||||
expect(inputs[0]).toHaveValue(prior);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(screen.getAllByRole("row")).toHaveLength(4);
|
||||
expect(screen.getAllByRole("textbox")[0]).toHaveValue(prior);
|
||||
});
|
||||
|
||||
it.each(["global", "project"] as const)("passes %s scope to the settings command", async (scope) => {
|
||||
render(<Harness page="llm" scope={scope} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test" }));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith("example.helper", "test", null, null, [], scope));
|
||||
});
|
||||
|
||||
it("indexes string-list enum options", () => {
|
||||
dispose();
|
||||
dispose = projectSettings("example.helper", {
|
||||
title: "Helper",
|
||||
properties: [],
|
||||
pages: [
|
||||
{
|
||||
id: "list",
|
||||
title: "Types",
|
||||
properties: [
|
||||
{
|
||||
key: "types",
|
||||
title: "Types",
|
||||
type: "array",
|
||||
default: [],
|
||||
items: {
|
||||
key: "item",
|
||||
title: "Item",
|
||||
type: "string",
|
||||
default: "feature",
|
||||
options: ["feature", "documentation"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(useSettingsPages.getState().pages.get("plugins.example-helper.settings:list")!.keywords).toContain(
|
||||
"documentation",
|
||||
);
|
||||
});
|
||||
@@ -62,6 +62,7 @@ export interface PluginsBackend {
|
||||
arg: string | null,
|
||||
editor: PluginEditor | null,
|
||||
paths: string[],
|
||||
settingsScope?: "global" | "project",
|
||||
): Promise<string | null>;
|
||||
/** Workbench events plugins subscribed to (see `eventKinds`). */
|
||||
postEvents(events: WorkbenchEvent[]): Promise<void>;
|
||||
@@ -184,8 +185,8 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
await call(commands.pluginUninstall(id));
|
||||
},
|
||||
reload: (id) => call(commands.pluginReload(id)),
|
||||
runCommand: (pluginId, commandId, arg, editor, paths) =>
|
||||
call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths)),
|
||||
runCommand: (pluginId, commandId, arg, editor, paths, settingsScope) =>
|
||||
call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths, settingsScope ?? null)),
|
||||
postEvents: async (events) => {
|
||||
await commands.pluginPostEvents(events);
|
||||
},
|
||||
|
||||
@@ -47,6 +47,53 @@ describe("localizeContributions", () => {
|
||||
expect(out[3]).toBe(items[3]);
|
||||
});
|
||||
|
||||
it("translates page, group, table-column and action text without changing values", () => {
|
||||
const field = {
|
||||
key: "Refresh",
|
||||
type: "array",
|
||||
title: "Word Tools",
|
||||
group: "Refresh",
|
||||
default: [{ name: "Cancel" }],
|
||||
items: {
|
||||
type: "object",
|
||||
title: "Word Tools",
|
||||
properties: [{ key: "name", title: "Cancel", description: "Refresh", options: ["Cancel"], default: "Cancel" }],
|
||||
},
|
||||
};
|
||||
const declaration = {
|
||||
title: "Word Tools",
|
||||
properties: [field],
|
||||
pages: [
|
||||
{ id: "Refresh", title: "Cancel", properties: [field], actions: [{ title: "Refresh", command: "Refresh" }] },
|
||||
],
|
||||
};
|
||||
const out = localizeContributions([item("a", "settings", declaration)], { a: fr })[0]!.decl;
|
||||
expect(out).toMatchObject({
|
||||
title: "Outils de mots",
|
||||
pages: [
|
||||
{
|
||||
id: "Refresh",
|
||||
title: "Annuler",
|
||||
actions: [{ title: "Actualiser", command: "Refresh" }],
|
||||
properties: [
|
||||
{
|
||||
key: "Refresh",
|
||||
title: "Outils de mots",
|
||||
group: "Actualiser",
|
||||
default: [{ name: "Cancel" }],
|
||||
items: {
|
||||
properties: [
|
||||
{ key: "name", title: "Annuler", description: "Actualiser", options: ["Cancel"], default: "Cancel" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(declaration.pages[0]!.title).toBe("Cancel");
|
||||
});
|
||||
|
||||
it("keeps the names of secrets and translates their labels", () => {
|
||||
const secret = item("a", "secrets", { id: "k", name: "Refresh", label: "Cancel", description: "Refresh" });
|
||||
expect(localizeContributions([secret], { a: fr })[0]!.decl).toMatchObject({
|
||||
|
||||
@@ -91,7 +91,10 @@ function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate
|
||||
const out: Record<string, unknown> = { ...(decl as Record<string, unknown>) };
|
||||
for (const key of keys) if (typeof out[key] === "string") out[key] = t(out[key]);
|
||||
if (Array.isArray(out.properties))
|
||||
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description"], t));
|
||||
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description", "group"], t));
|
||||
if (Array.isArray(out.pages)) out.pages = out.pages.map((p) => localizeDecl(p, ["title"], t));
|
||||
if (Array.isArray(out.actions)) out.actions = out.actions.map((a) => localizeDecl(a, ["title"], t));
|
||||
if (out.items) out.items = localizeDecl(out.items, ["title", "description", "group"], t);
|
||||
if (Array.isArray(out.variables)) out.variables = out.variables.map((v) => localizeDecl(v, ["prompt"], t));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/** IDE-rendered plugin settings; every control edits the dialog's staged form. */
|
||||
import { useRef, useState } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, Checkbox, IconButton, Input, Select, T } from "@/ui";
|
||||
import { registerSettingsPage, useSettingsForm } from "@/workbench/contributions";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import styles from "./PluginSettings.module.css";
|
||||
|
||||
export interface SettingSchema {
|
||||
key: string;
|
||||
type: "boolean" | "string" | "number" | "integer" | "array" | "object";
|
||||
title: string;
|
||||
description?: string | null;
|
||||
default: unknown;
|
||||
options?: string[] | null;
|
||||
multiline?: boolean;
|
||||
group?: string | null;
|
||||
items?: SettingSchema | null;
|
||||
properties?: SettingSchema[];
|
||||
}
|
||||
interface SettingsAction {
|
||||
title: string;
|
||||
command: string;
|
||||
}
|
||||
interface SettingsPage {
|
||||
id: string;
|
||||
title: string;
|
||||
properties: SettingSchema[];
|
||||
actions?: SettingsAction[];
|
||||
}
|
||||
export interface SettingsDecl {
|
||||
title: string;
|
||||
properties: SettingSchema[];
|
||||
pages?: SettingsPage[];
|
||||
}
|
||||
type PluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** `null` removes an override, restoring its default. */
|
||||
export function withPluginSetting(
|
||||
settings: PluginSettings | undefined,
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PluginSettings {
|
||||
const own = { ...(settings?.[pluginId] ?? {}) };
|
||||
if (value === null) delete own[key];
|
||||
else own[key] = value;
|
||||
const next = { ...(settings ?? {}) };
|
||||
if (Object.keys(own).length) next[pluginId] = own;
|
||||
else delete next[pluginId];
|
||||
return next;
|
||||
}
|
||||
|
||||
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
|
||||
|
||||
function ValueControl({
|
||||
s,
|
||||
value,
|
||||
set,
|
||||
label = s.title,
|
||||
cell = false,
|
||||
}: {
|
||||
s: SettingSchema;
|
||||
value: unknown;
|
||||
set(value: unknown): void;
|
||||
label?: string;
|
||||
cell?: boolean;
|
||||
}) {
|
||||
if (s.type === "array") return <ArrayControl s={s} value={value as unknown[]} set={set} />;
|
||||
if (s.type === "boolean")
|
||||
return <Checkbox checked={value === true} label={cell ? undefined : s.title} aria-label={label} onChange={set} />;
|
||||
if (s.options)
|
||||
return (
|
||||
<Select
|
||||
aria-label={label}
|
||||
value={String(value)}
|
||||
options={s.options.map((o) => ({ value: o, label: o }))}
|
||||
onChange={set}
|
||||
/>
|
||||
);
|
||||
if (s.multiline && s.type === "string")
|
||||
return (
|
||||
<textarea
|
||||
className={styles.multiline}
|
||||
aria-label={label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Input
|
||||
aria-label={label}
|
||||
type={s.type === "string" ? "text" : "number"}
|
||||
step={s.type === "integer" ? 1 : "any"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (s.type === "string") set(raw);
|
||||
else if (raw.trim() && Number.isFinite(Number(raw)))
|
||||
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayControl({ s, value, set }: { s: SettingSchema; value: unknown[]; set(value: unknown): void }) {
|
||||
const item = s.items!;
|
||||
const columns = item.type === "object" ? item.properties! : [item];
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
const update = (index: number, field: SettingSchema, next: unknown) =>
|
||||
set(
|
||||
rows.map((row, i) =>
|
||||
i !== index ? row : item.type === "object" ? { ...(row as Record<string, unknown>), [field.key]: next } : next,
|
||||
),
|
||||
);
|
||||
const move = (index: number, delta: number) => {
|
||||
const next = [...rows];
|
||||
[next[index], next[index + delta]] = [next[index + delta], next[index]];
|
||||
set(next);
|
||||
};
|
||||
return (
|
||||
<div className={styles.array}>
|
||||
<div className={styles.scroll}>
|
||||
<table aria-label={s.title}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.key}>{item.type === "object" ? c.title : s.title}</th>
|
||||
))}
|
||||
<th>
|
||||
<T>Actions</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key}>
|
||||
<ValueControl
|
||||
s={c}
|
||||
cell
|
||||
label={`${s.title}: ${c.title} ${i + 1}`}
|
||||
value={item.type === "object" ? (row as Record<string, unknown>)[c.key] : row}
|
||||
set={(v) => update(i, c, v)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<div className={styles.buttons}>
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="arrow-up"
|
||||
title="Move Up"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="arrow-down"
|
||||
title="Move Down"
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="trash"
|
||||
title="Remove"
|
||||
onClick={() => set(rows.filter((_, index) => index !== i))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.add}
|
||||
disabled={rows.length >= 1000}
|
||||
onClick={() => set([...rows, structuredClone(item.default)])}
|
||||
>
|
||||
<T>Add</T>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accepts(s: SettingSchema, value: unknown): boolean {
|
||||
if (s.type === "array")
|
||||
return Array.isArray(value) && value.length <= 1000 && !!s.items && value.every((v) => accepts(s.items!, v));
|
||||
if (s.type === "object")
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === s.properties?.length &&
|
||||
s.properties.every((p) => accepts(p, (value as Record<string, unknown>)[p.key]))
|
||||
);
|
||||
if (s.type === "boolean") return typeof value === "boolean";
|
||||
if (s.type === "number" || s.type === "integer")
|
||||
return typeof value === "number" && Number.isFinite(value) && (s.type !== "integer" || Number.isInteger(value));
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
new TextEncoder().encode(value).length <= 65536 &&
|
||||
(!s.options || s.options.includes(value))
|
||||
);
|
||||
}
|
||||
|
||||
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const form = useSettingsForm();
|
||||
const all = form.useValue("plugins") as PluginSettings | undefined;
|
||||
const stored = all?.[pluginId]?.[s.key];
|
||||
const value = accepts(s, stored) ? stored : s.default;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
{s.type !== "boolean" && <span>{s.title}</span>}
|
||||
<ValueControl
|
||||
s={s}
|
||||
value={value}
|
||||
set={(v) => {
|
||||
const valid = accepts(s, v);
|
||||
setInvalid(!valid);
|
||||
if (valid) form.setMapEntry("plugins", [pluginId, s.key], v === s.default ? null : v);
|
||||
}}
|
||||
/>
|
||||
{s.description && <div className={styles.description}>{s.description}</div>}
|
||||
{invalid && (
|
||||
<p role="alert" className={styles.error}>
|
||||
<T>The value exceeds this setting’s limits.</T>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageAction({ pluginId, action }: { pluginId: string; action: SettingsAction }) {
|
||||
const form = useSettingsForm();
|
||||
const lock = useRef(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const run = async () => {
|
||||
if (lock.current) return;
|
||||
lock.current = true;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!(await form.apply())) throw new Error(tx`Could not apply settings.`);
|
||||
await pluginsBackend().runCommand(pluginId, action.command, null, null, [], form.scope);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
lock.current = false;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
<>{action.title}</>
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className={styles.error}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function settingsPage(pluginId: string, page: Pick<SettingsPage, "properties" | "actions">) {
|
||||
return function PluginSettingsPage() {
|
||||
const groups = new Map<string, SettingSchema[]>();
|
||||
for (const s of page.properties) {
|
||||
const key = s.group ?? "";
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(s);
|
||||
}
|
||||
return (
|
||||
<div className={`${styles.page} ${page.properties.some((s) => s.type === "array") ? "" : styles.scalars}`}>
|
||||
<p className={styles.description}>
|
||||
<T>Settings of the plugin; projects can override them.</T>
|
||||
</p>
|
||||
{[...groups].map(([title, properties]) => (
|
||||
<section key={title} className={styles.group}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{properties.map((s) => (
|
||||
<SettingControl key={s.key} pluginId={pluginId} s={s} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{!!page.actions?.length && (
|
||||
<>
|
||||
<p className={styles.description}>
|
||||
<T>Running an action applies pending settings first.</T>
|
||||
</p>
|
||||
<div className={styles.buttons}>
|
||||
{page.actions.map((a, index) => (
|
||||
<PageAction key={index} pluginId={pluginId} action={a} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function keywords(page: Pick<SettingsPage, "properties" | "actions">): string[] {
|
||||
const fieldWords = (s: SettingSchema): string[] => [
|
||||
s.key,
|
||||
s.title,
|
||||
s.description ?? "",
|
||||
s.group ?? "",
|
||||
...(s.options ?? []),
|
||||
...(s.items ? fieldWords(s.items) : []),
|
||||
...(s.properties ?? []).flatMap(fieldWords),
|
||||
];
|
||||
return [...page.properties.flatMap(fieldWords), ...(page.actions ?? []).map((a) => a.title)];
|
||||
}
|
||||
|
||||
export function projectSettings(pluginId: string, decl: SettingsDecl): () => void {
|
||||
const id = pluginSettingsPageId(pluginId);
|
||||
const pages = decl.pages ?? [];
|
||||
const dispose = [
|
||||
registerSettingsPage({
|
||||
id,
|
||||
title: decl.title,
|
||||
projectScope: true,
|
||||
keywords: ["plugin", pluginId, ...keywords(decl)],
|
||||
component: pages.length ? undefined : settingsPage(pluginId, decl),
|
||||
}),
|
||||
];
|
||||
for (const p of pages)
|
||||
dispose.push(
|
||||
registerSettingsPage({
|
||||
id: `${id}.settings:${p.id}`,
|
||||
title: p.title,
|
||||
projectScope: true,
|
||||
keywords: [pluginId, decl.title, ...keywords(p)],
|
||||
component: settingsPage(pluginId, p),
|
||||
}),
|
||||
);
|
||||
return () => dispose.forEach((d) => d());
|
||||
}
|
||||
@@ -14,22 +14,12 @@
|
||||
* - `fileTemplates`: entries of the New menu;
|
||||
* - `commitChecks`: before-commit checks listed in Commit Options, run by the plugin's code.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import type { PluginInfo } from "@/bindings";
|
||||
import { getAction } from "@/platform/actions";
|
||||
import { evaluateWhen, getContext, useContextKeys } from "@/platform/context";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
|
||||
import {
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
T,
|
||||
addFileIconProvider,
|
||||
fileIconName,
|
||||
registerExternalIcons,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { addFileIconProvider, fileIconName, registerExternalIcons, type MenuItem } from "@/ui";
|
||||
import { addContributedFileTemplate } from "@/workbench/contributions";
|
||||
import {
|
||||
registerEditorKind,
|
||||
@@ -38,9 +28,7 @@ import {
|
||||
registerMenuGroup,
|
||||
registerMenuSlotItem,
|
||||
registerServiceProvider,
|
||||
registerSettingsPage,
|
||||
registerTextEditorWrapper,
|
||||
useSettingsForm,
|
||||
type ContextMenuId,
|
||||
type ContributedCommitProblem,
|
||||
type ToolWindowAnchor,
|
||||
@@ -76,7 +64,7 @@ import { PluginView } from "./views/PluginView";
|
||||
import { pluginServiceProvider } from "./views/serviceProvider";
|
||||
import { WebView } from "./views/WebView";
|
||||
import { registerLogActions } from "./views/logActions";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
import { projectSettings, type SettingsDecl } from "./settings";
|
||||
|
||||
// --- menus ------------------------------------------------------------------------
|
||||
|
||||
@@ -274,99 +262,7 @@ interface StatusDecl {
|
||||
|
||||
// --- settings ---------------------------------------------------------------------
|
||||
|
||||
export interface SettingSchema {
|
||||
key: string;
|
||||
type: "boolean" | "string" | "number" | "integer";
|
||||
title: string;
|
||||
description?: string | null;
|
||||
default: unknown;
|
||||
options?: string[] | null;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
interface SettingsDecl {
|
||||
title: string;
|
||||
properties: SettingSchema[];
|
||||
}
|
||||
|
||||
type PluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** `settings` with `key` of `pluginId` set (`null`: back to the default). */
|
||||
export function withPluginSetting(
|
||||
settings: PluginSettings | undefined,
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PluginSettings {
|
||||
const own = { ...(settings?.[pluginId] ?? {}) };
|
||||
if (value === null) delete own[key];
|
||||
else own[key] = value;
|
||||
const next = { ...(settings ?? {}) };
|
||||
if (Object.keys(own).length) next[pluginId] = own;
|
||||
else delete next[pluginId];
|
||||
return next;
|
||||
}
|
||||
|
||||
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
|
||||
const form = useSettingsForm();
|
||||
const all = form.useValue("plugins") as PluginSettings | undefined;
|
||||
const value = all?.[pluginId]?.[s.key] ?? s.default;
|
||||
const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v));
|
||||
const id = `plugin-setting-${pluginId}-${s.key}`;
|
||||
const description = s.description ? <div className={styles.muted}>{s.description}</div> : null;
|
||||
if (s.type === "boolean")
|
||||
return (
|
||||
<div className={styles.settingRow}>
|
||||
<Checkbox checked={value === true} label={s.title} onChange={set} />
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.settingRow}>
|
||||
<label htmlFor={id}>{s.title}</label>
|
||||
{s.options ? (
|
||||
<Select
|
||||
aria-label={s.title}
|
||||
value={String(value)}
|
||||
options={s.options.map((o) => ({ value: o, label: o }))}
|
||||
onChange={set}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
type={s.type === "string" ? "text" : "number"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (s.type === "string") set(raw);
|
||||
else if (raw.trim() !== "" && Number.isFinite(Number(raw)))
|
||||
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function settingsPage(pluginId: string, decl: SettingsDecl) {
|
||||
return function PluginSettingsPage() {
|
||||
const properties = useMemo(() => decl.properties, []);
|
||||
return (
|
||||
<div className={styles.settingsPage}>
|
||||
<p className={styles.muted}>
|
||||
<T>Settings of the plugin; projects can override them.</T>
|
||||
</p>
|
||||
{properties.map((s) => (
|
||||
<SettingControl key={s.key} pluginId={pluginId} s={s} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Settings page id of a plugin (dots nest pages, so they become dashes). */
|
||||
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
|
||||
export { withPluginSetting, pluginSettingsPageId } from "./settings";
|
||||
|
||||
// --- views and custom editors -----------------------------------------------------
|
||||
|
||||
@@ -544,14 +440,7 @@ export function defineViewUiPoints(): () => void {
|
||||
}),
|
||||
defineUiPoint<SettingsDecl>("settings", {
|
||||
title: "Settings",
|
||||
project: ({ pluginId, decl }) =>
|
||||
registerSettingsPage({
|
||||
id: pluginSettingsPageId(pluginId),
|
||||
title: decl.title,
|
||||
projectScope: true,
|
||||
keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])],
|
||||
component: settingsPage(pluginId, decl),
|
||||
}),
|
||||
project: ({ pluginId, decl }) => projectSettings(pluginId, decl),
|
||||
}),
|
||||
defineUiPoint<ViewDecl>("views", {
|
||||
orderSensitive: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resetPage,
|
||||
stage,
|
||||
stageSshSecret,
|
||||
stageMapEntry,
|
||||
SSH_SETTINGS_PAGE,
|
||||
stagedValue,
|
||||
useSettingsDialog,
|
||||
@@ -304,3 +305,37 @@ describe("validateSettings", () => {
|
||||
expect(getAt(out, "editor.nope.deeper")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("literal map entry staging", () => {
|
||||
beforeEach(() => {
|
||||
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS) as Settings, project: null });
|
||||
openSettings();
|
||||
});
|
||||
it("preserves literal dots, per-page ownership, and merges one write per scope", async () => {
|
||||
stageMapEntry("templates", "global", "plugins", ["example.helper", "prompt.with.dots"], ["one"]);
|
||||
stageMapEntry("llm", "global", "plugins", ["example.helper", "profiles"], [{ name: "Local" }]);
|
||||
stageMapEntry("other", "project", "plugins", ["other.plugin", "enabled"], true);
|
||||
expect(pendingChanges(useSettingsDialog.getState())).toHaveLength(2);
|
||||
resetPage("llm");
|
||||
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toEqual({
|
||||
"example.helper": { "prompt.with.dots": ["one"] },
|
||||
});
|
||||
const backend = fakeBackend();
|
||||
expect(await applySettings(backend.deps)).toBe(true);
|
||||
expect(backend.set.mock.calls.map((c) => [c[0], c[1], JSON.parse(c[2])])).toEqual([
|
||||
["global", "plugins", { "example.helper": { "prompt.with.dots": ["one"] } }],
|
||||
["project", "plugins", { "other.plugin": { enabled: true } }],
|
||||
]);
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
it("removes only its own override and clears a reverted edit", () => {
|
||||
const base = validateSettings({ plugins: { "example.helper": { "a.b": 1, keep: true } } });
|
||||
useSettingsDialog.setState({ base, globalBase: base });
|
||||
stageMapEntry("a", "global", "plugins", ["example.helper", "a.b"], null);
|
||||
expect(stagedValue(useSettingsDialog.getState(), "global", "plugins")).toEqual({
|
||||
"example.helper": { keep: true },
|
||||
});
|
||||
stageMapEntry("a", "global", "plugins", ["example.helper", "a.b"], 1);
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
/** Page context for the form components: which page / scope they edit and the search query. */
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
import type { SettingsForm } from "@/workbench/contributions";
|
||||
import { externalKey, stage, stageExternal, stagedValue, useSettingsDialog, type Scope } from "../model/staging";
|
||||
import {
|
||||
applySettings,
|
||||
externalKey,
|
||||
stage,
|
||||
stageMapEntry,
|
||||
stageExternal,
|
||||
stagedValue,
|
||||
useSettingsDialog,
|
||||
type Scope,
|
||||
} from "../model/staging";
|
||||
import { labelMatches } from "../model/pages";
|
||||
|
||||
export interface PageContextValue {
|
||||
@@ -36,8 +45,10 @@ export function useSettingsFormFor(pageId: string, scope: Scope): SettingsForm {
|
||||
return useMemo(
|
||||
() => ({
|
||||
scope,
|
||||
apply: () => applySettings(),
|
||||
useValue: (key: string) => useStagedValue(scope, key),
|
||||
set: (key: string, value: unknown) => stage(pageId, scope, key, value),
|
||||
setMapEntry: (key: string, path: string[], value: unknown) => stageMapEntry(pageId, scope, key, path, value),
|
||||
useExternal: <T>(key: string, base: T, apply: (value: T) => Promise<T>) =>
|
||||
useExternalValue(pageId, key, base, apply),
|
||||
}),
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface CustomKeymapSettings {
|
||||
}
|
||||
|
||||
export interface ValidSettings extends WorkflowSettings {
|
||||
plugins: Record<string, Record<string, unknown>>;
|
||||
general: { reopenLastProject: boolean; confirmExit: boolean } & GeneralExtras;
|
||||
appearance: { theme: ThemeId; accent: string; uiFontSize: number; locale: Locale } & AppearanceExtras;
|
||||
editor: {
|
||||
@@ -121,6 +122,7 @@ function validateSpelling(raw: unknown): SpellingValid {
|
||||
export { ACCENT_PRESETS } from "@/workbench/theme";
|
||||
|
||||
export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
plugins: {},
|
||||
general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS },
|
||||
appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS },
|
||||
editor: {
|
||||
@@ -213,6 +215,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
customKeymaps[id] = { name: str(k.name, id), parent: str(k.parent, d.keymap), overrides: bindingMap(k.overrides) };
|
||||
}
|
||||
return {
|
||||
plugins: Object.fromEntries(Object.entries(obj(root.plugins)).map(([id, values]) => [id, obj(values)])),
|
||||
general: {
|
||||
...validateGeneralExtras(g),
|
||||
reopenLastProject: bool(g.reopenLastProject, d.general.reopenLastProject),
|
||||
|
||||
@@ -13,6 +13,8 @@ export type Scope = "global" | "project";
|
||||
export interface StagedChange {
|
||||
scope: Scope;
|
||||
key: string;
|
||||
/** Literal map segments; dots inside plugin IDs and keys are data. */
|
||||
path?: string[];
|
||||
value: unknown;
|
||||
/** Page that made the change (Reset discards per page). */
|
||||
page: string;
|
||||
@@ -217,17 +219,67 @@ export function setPageScope(pageId: string, scope: Scope) {
|
||||
|
||||
const isInside = (key: string, parent: string) => key.startsWith(`${parent}.`);
|
||||
|
||||
function mapEntry(value: unknown, path: readonly string[]): unknown {
|
||||
for (const key of path)
|
||||
value =
|
||||
value && typeof value === "object" && Object.hasOwn(value, key)
|
||||
? (value as Record<string, unknown>)[key]
|
||||
: undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function putEntry(value: unknown, path: readonly string[], next: unknown): unknown {
|
||||
const [head, ...tail] = path;
|
||||
if (head === undefined) return next;
|
||||
const out = { ...(value as Record<string, unknown> | undefined) };
|
||||
const child = tail.length ? putEntry(out[head], tail, next) : next;
|
||||
if (child == null || (tail.length && typeof child === "object" && !Object.keys(child).length)) delete out[head];
|
||||
else Object.defineProperty(out, head, { value: child, enumerable: true, configurable: true, writable: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge literal map entries only at the persistence boundary; their page ownership stays separate. */
|
||||
function mergedChanges(s: Bases & Pick<SettingsDialogState, "staged">): StagedChange[] {
|
||||
const all = Object.values(s.staged);
|
||||
const roots = new Map(all.filter((c) => !c.path).map((c) => [stageKey(c.scope, c.key), { ...c }]));
|
||||
for (const c of all) {
|
||||
if (!c.path) continue;
|
||||
const id = stageKey(c.scope, c.key);
|
||||
const root = roots.get(id) ?? {
|
||||
scope: c.scope,
|
||||
key: c.key,
|
||||
page: c.page,
|
||||
value: getAt(baseFor(s, c.scope), c.key),
|
||||
};
|
||||
root.value = putEntry(root.value, c.path, c.value);
|
||||
roots.set(id, root);
|
||||
}
|
||||
return [...roots.values()];
|
||||
}
|
||||
|
||||
/** Stage one literal map entry independently of every other page. */
|
||||
export function stageMapEntry(page: string, scope: Scope, key: string, path: string[], value: unknown) {
|
||||
if (!path.length) return stage(page, scope, key, value);
|
||||
useSettingsDialog.setState((s) => {
|
||||
const staged = { ...s.staged };
|
||||
const id = `${stageKey(scope, key)}|${JSON.stringify(path)}`;
|
||||
const next = value ?? null;
|
||||
if (deepEqual(next ?? undefined, mapEntry(getAt(baseFor(s, scope), key), path))) delete staged[id];
|
||||
else staged[id] = { scope, key, path, value: next, page };
|
||||
return { staged, error: null };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current value of `key` as the page shows it: staged edit, else the effective
|
||||
* value. Edits staged for a parent (a whole map entry) or inside `key` (one
|
||||
* field of it) show through too.
|
||||
*/
|
||||
export function stagedValue(s: Bases & Pick<SettingsDialogState, "staged">, scope: Scope, key: string): unknown {
|
||||
const hit = s.staged[stageKey(scope, key)];
|
||||
const changes = mergedChanges(s);
|
||||
const hit = changes.find((c) => c.scope === scope && c.key === key);
|
||||
if (hit) return hit.value;
|
||||
const related = Object.values(s.staged).filter(
|
||||
(c) => c.scope === scope && (isInside(key, c.key) || isInside(c.key, key)),
|
||||
);
|
||||
const related = changes.filter((c) => c.scope === scope && (isInside(key, c.key) || isInside(c.key, key)));
|
||||
let value = getAt(baseFor(s, scope), key);
|
||||
for (const c of related.sort((a, b) => a.key.length - b.key.length)) {
|
||||
value = isInside(key, c.key)
|
||||
@@ -251,7 +303,8 @@ export function stage(page: string, scope: Scope, key: string, value: unknown) {
|
||||
staged[stageKey(scope, parent.key)] = { ...parent, value: merged };
|
||||
return { staged, error: null };
|
||||
}
|
||||
for (const [id, c] of Object.entries(staged)) if (c.scope === scope && isInside(c.key, key)) delete staged[id];
|
||||
for (const [id, c] of Object.entries(staged))
|
||||
if (c.scope === scope && (isInside(c.key, key) || (c.key === key && c.path))) delete staged[id];
|
||||
const id = stageKey(scope, key);
|
||||
const next = value === undefined ? null : value;
|
||||
// Back to the scope's current value: nothing to write.
|
||||
@@ -296,7 +349,7 @@ export function resetPage(page: string) {
|
||||
|
||||
/** Writes to perform, global layer first so a project value still wins afterwards. */
|
||||
export function pendingChanges(s: Bases & Pick<SettingsDialogState, "staged">): StagedChange[] {
|
||||
return Object.values(s.staged)
|
||||
return mergedChanges(s)
|
||||
.filter((c) => !deepEqual(c.value ?? undefined, getAt(baseFor(s, c.scope), c.key)))
|
||||
.sort((a, b) => (a.scope === b.scope ? a.key.localeCompare(b.key) : a.scope === "global" ? -1 : 1));
|
||||
}
|
||||
@@ -375,7 +428,9 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise<
|
||||
.filter((c) => c.scope === "global" && done.has(stageKey(c.scope, c.key)))
|
||||
.reduce((g, c) => putAt(g, c.key, c.value), st.globalBase)
|
||||
: base,
|
||||
staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))),
|
||||
staged: Object.fromEntries(
|
||||
Object.entries(st.staged).filter(([id, c]) => !done.has(stageKey(c.scope, c.key)) || initial.staged[id] !== c),
|
||||
),
|
||||
}));
|
||||
await loadGlobalBase();
|
||||
if (!error) {
|
||||
|
||||
@@ -44,6 +44,10 @@ export function TestSettingsForm({
|
||||
const form = useMemo<SettingsForm>(
|
||||
() => ({
|
||||
scope,
|
||||
apply: async () => true,
|
||||
setMapEntry: () => {
|
||||
throw new Error("Map entries are not supported by this stand-in");
|
||||
},
|
||||
useExternal: () => {
|
||||
throw new Error("External settings are not supported by this stand-in");
|
||||
},
|
||||
|
||||
@@ -396,5 +396,8 @@
|
||||
"Modified": "Modified",
|
||||
"Before": "Before",
|
||||
"After": "After",
|
||||
"The plugin did not save the content": "The plugin did not save the content"
|
||||
"The plugin did not save the content": "The plugin did not save the content",
|
||||
"Running an action applies pending settings first.": "Running an action applies pending settings first.",
|
||||
"Could not apply settings.": "Could not apply settings.",
|
||||
"The value exceeds this setting’s limits.": "The value exceeds this setting’s limits."
|
||||
}
|
||||
@@ -6749,5 +6749,8 @@
|
||||
"Run Content Editor Action": "运行内容编辑器操作",
|
||||
"Before": "更改前",
|
||||
"After": "更改后",
|
||||
"The plugin did not save the content": "插件未保存内容"
|
||||
"The plugin did not save the content": "插件未保存内容",
|
||||
"Running an action applies pending settings first.": "运行动作前会先应用待保存的设置。",
|
||||
"Could not apply settings.": "无法应用设置。",
|
||||
"The value exceeds this setting’s limits.": "该值超出了此设置的限制。"
|
||||
}
|
||||
@@ -905,11 +905,15 @@ export interface SettingsPageContribution {
|
||||
* edits not applied yet. Edits are written on Apply / OK and dropped on Cancel.
|
||||
*/
|
||||
export interface SettingsForm {
|
||||
/** Applies pending edits; false prevents an action from running after a failed save. */
|
||||
apply(): Promise<boolean>;
|
||||
scope: "global" | "project";
|
||||
/** Value of a dotted key (map entries too, e.g. `languageServers.ruff.args`). */
|
||||
useValue(key: string): unknown;
|
||||
/** Stages a value; `null` removes the key on Apply. */
|
||||
set(key: string, value: unknown): void;
|
||||
/** Stages a literal map entry with independent page/Reset ownership. */
|
||||
setMapEntry(key: string, path: string[], value: unknown): void;
|
||||
/** Values stored outside settings JSON; the writer runs on Apply / OK and returns the saved value. */
|
||||
useExternal<T>(
|
||||
key: string,
|
||||
|
||||
Reference in new issue
Block a user