diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b02c3b6e1..3cbe9f920 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "fileDialog": app.picks.waiting_kind(), }); let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); + for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { + if let Some(v) = inspected["ui"].get_mut(*field) { + hide_library_ids(v, &ids); + } + } + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + if let Some(sets) = inspected["ui"].get_mut("action_sets") { + swap_library_params(sets, &pairs); + } inspected } +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel +/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. +const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; + /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -322,9 +334,11 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a -/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. When another of those ids, or a library loaded in the session, already +/// has that id, the next free one /// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that /// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { @@ -333,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut ids); } + collect_library_params(&ui["action_sets"], &mut ids); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { collect_library_panel_ids(layout, &mut ids); @@ -343,10 +358,15 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { add_library_id(id, &mut ids); } let mut hidden = Vec::new(); - let mut taken = std::collections::BTreeSet::new(); + // The ids of the libraries loaded in the session, so that a hidden one does not take the id of + // one loaded without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let mut taken: std::collections::BTreeSet = loaded.map(|info| info.id.clone()).collect(); for id in ids { let path = &id["loaded/".len()..]; - let name = vectorcraft_engine::cmd::fileio::file_name(path); + // Either separator: a path saved on another platform counts as a path here too. + let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); if name != path && !readable(path) { hidden.push((id, name)); } else { @@ -402,30 +422,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + swap_library_ids(v, &pairs); +} + +/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a +/// workspace's name. [`swap_library_ids`] leaves it as written. +const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; + +/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), +/// in strings and keys, except in the [`TEXT_FIELDS`]. +fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { + if pairs.is_empty() { return; } - let hide = |s: &mut String| { - for (id, shown) in ids { - if s.contains(id.as_str()) { - *s = s.replace(id.as_str(), shown); + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; } } - }; - match v { - Value::String(s) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); - fields.insert(key, item); + for (key, mut item) in std::mem::take(fields) { + if TEXT_FIELDS.contains(&key.as_str()) { + fields.insert(key, item); + continue; + } + swap_library_ids(&mut item, pairs); + fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); } } _ => {} } } +/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, +/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends +/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. +fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option { + let mut out: Option = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if !text.contains(from) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from) { + let end = at + from.len(); + let starts = at == 0 || text[..at].ends_with([':', '"', '`']); + let ends = end == text.len() || text[end..].starts_with(['"', '`']); + if at >= copied && starts && ends { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? +fn names_a_loaded_library(v: &Value) -> bool { + match v { + Value::String(s) => s.contains("loaded/"), + Value::Array(items) => items.iter().any(names_a_loaded_library), + Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), + _ => false, + } +} + /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -439,9 +510,43 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } +/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in +/// `Folder` (`destinationFolder`, `searchFolder`)? +fn is_path_param(key: &str) -> bool { + matches!(key, "path" | "folder") || key.ends_with("Folder") +} + +/// The params of each step of each action of action set `set` (`ui.action_sets`). +fn steps_mut(set: &mut Value) -> impl Iterator> { + let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); + let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); + steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) +} + +/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the +/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +fn leave_out_unreadable_params(params: &mut serde_json::Map) { + params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); + if let Some(Value::Array(paths)) = params.get_mut("paths") { + paths.retain(|p| p.as_str().is_none_or(readable)); + } + for v in params.values_mut() { + leave_out_unreadable_values(v); + } +} + +/// [`leave_out_unreadable_params`] on each object `v` holds. +fn leave_out_unreadable_values(v: &mut Value) { + match v { + Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + _ => {} + } +} + /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside -/// the read root. The UI state itself is not changed. +/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths +/// in recorded actions' steps outside the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -453,6 +558,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { } } } + for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step); + } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -468,8 +576,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) { for key in PREF_FOLDERS { - let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; + let path = written.trim(); + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { continue; } prefs.remove(key); @@ -499,7 +608,83 @@ fn modifiers(p: &Value) -> egui::Modifiers { egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } } +/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, +/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking +/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, +/// below}`) and a list of panels. +const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; + +/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is +/// translated. +const DOCKING_ACTION: &str = "action"; + +/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` +/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id +/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + if shown.is_empty() || !swap_library_params(&mut params, &shown) { + return dispatch(app, ctx, req); + } + let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + match dispatch(app, ctx, &named) { + Outcome::Done(mut reply) => { + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) + } + outcome => outcome, + } +} + +/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its +/// `to` id → whether one was. +fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { + match params { + Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), + Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { + let this = match item { + Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), + Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { + let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); + this | swapped + }), + Value::Object(_) if key == DOCKING_ACTION => { + let before = item.clone(); + swap_library_ids(item, pairs); + *item != before + } + _ => swap_library_params(item, pairs), + }; + this | swapped + }), + _ => false, + } +} + +/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: +/// a library's id or its panel's id. +fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet) { + match v { + Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + if !LIBRARY_PARAMS.contains(&key.as_str()) { + collect_library_params(item, ids); + continue; + } + for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { + add_library_id(s, ids); + collect_library_panel_ids(&json!(s), ids); + } + } + } + _ => {} + } +} + +fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index f725a0819..f3aee6ca7 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,124 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: +/// status text that is that id stays as written. +#[test] +fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { + let (mut app, tx, base) = confined("inspect-status-id"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = theirs.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/ (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -519,6 +637,71 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that hold it stay as written. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + let query = format!("library:swatches:{hidden}"); + app.ui.palette_query = query.clone(); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 33a7e2a68..b8265bdfd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,14 +129,24 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, + a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/`, + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + either separator counts as a path, also one saved on another platform. When another hidden library has the same + file name, the libraries take `loaded/`, `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both - replies. The other entries keep their order, and the app's own state is not changed. + replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep + their order, and the app's own state is not changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, + `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}` + reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: + a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice