From 774f7568dadc1e915be761a422051a470fd05645 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:40:36 -0800 Subject: [PATCH] MCP: ui.inspect hides every loaded library path outside the read roots Under --automation-read-root, ui.inspect and ui.docking hid a library's path only when its panel was in ui.library_panels. A library used as the Color Guide's limit, or named by a closed panel's place in ui.docking_hidden, a workspace layout or the docking layout, still showed its full loaded/ id. The ids to hide now come from every place the UI state refers to a library: the library panels, the Color Guide's limit, and the panel ids in the docking layout, closed panels, icon and collapsed lists and workspace layouts. Typed text such as a command palette query is not read as an id. Two hidden libraries with the same file name no longer share one id, which dropped one of them from an object keyed by panel id. They show as loaded/, loaded/ (2) and so on, in the order of their full ids, and a library has the same id in ui.inspect and ui.docking. Docs: docs/mcp.md. --- crates/ui-egui/src/control.rs | 98 +++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 110 +++++++++++++++++++++++++ docs/mcp.md | 7 +- 3 files changed, 201 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9f27448924..b02c3b6e1d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,22 +308,95 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut inspected, &ids); inspected } -/// The ids of the loaded libraries (`loaded/`) whose file the request may not read, each with -/// the id the reply shows instead: `loaded/`, as a library loaded without a path is named. +/// 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. +const PANEL_FIELDS: [&str; 7] = + ["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"]; + +/// 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 +/// 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)> { - app.ui - .library_panels - .iter() - .filter_map(|panel| { - let path = panel.id.strip_prefix("loaded/")?; - let name = vectorcraft_engine::cmd::fileio::file_name(path); - (name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}"))) + let mut ids = std::collections::BTreeSet::new(); + let ui = serde_json::to_value(&app.ui).unwrap_or_default(); + for field in PANEL_FIELDS { + collect_library_panel_ids(&ui[field], &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); + } + } + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + add_library_id(id, &mut ids); + } + let mut hidden = Vec::new(); + let mut taken = std::collections::BTreeSet::new(); + for id in ids { + let path = &id["loaded/".len()..]; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + if name != path && !readable(path) { + hidden.push((id, name)); + } else { + taken.insert(id); + } + } + let mut hidden = hidden + .into_iter() + .map(|(id, name)| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + (id, shown) }) - .collect() + .collect::>(); + // The longest first, so that an id that begins another one does not replace part of it. + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + hidden +} + +/// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). +fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") { + ids.insert(id.to_string()); + } +} + +/// Adds to `ids` the loaded library id of every library panel id (`library::loaded/`) +/// that `v` holds as a string or a key. +fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet) { + let add = |s: &str, ids: &mut std::collections::BTreeSet| { + if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) { + add_library_id(id, ids); + } + }; + match v { + Value::String(s) => add(s, ids), + Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, ids); + collect_library_panel_ids(item, ids); + } + } + _ => {} + } } /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's @@ -445,7 +518,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.inspect" => ok(inspect(app, ctx)), "ui.docking" => { let mut docking = crate::panel_docking::inspect(app, ctx); - hide_library_ids(&mut docking, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut docking, &ids); ok(docking) } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index a369ee002e..f725a0819c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,116 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { 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() +} + +/// `ui.inspect` hides the path of a library loaded from outside the read root wherever the reply holds +/// its id, also when its panel is closed: here only the Color Guide's limit and a closed panel's place +/// name it. +#[test] +fn ui_inspect_hides_a_closed_librarys_path_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-closed-lib"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(ui["color_guide_limit"], "loaded/b.svg", "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key("library:swatches:loaded/b.svg"), "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_guide_limit"], json!(theirs), "the person's"); + let _ = std::fs::remove_dir_all(base); +} + +/// Two libraries loaded from outside the read root with the same file name stay two in `ui.inspect`: +/// the second one shows as `loaded/ (2)`, the same in every place the reply names it. +#[test] +fn ui_inspect_keeps_two_hidden_libraries_with_one_file_name_apart() { + let (mut app, tx, base) = confined("inspect-same-name"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + 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(&x), panel(&y)]; + for id in [&x, &y] { + app.ui.docking_hidden.insert(format!("library:swatches:{id}"), hidden_place()); + } + app.ui.color_guide_limit = y.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + let ids: Vec<_> = ui["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/b.svg"), json!("loaded/b.svg (2)")], "{r}"); + let keys: Vec<_> = ui["docking_hidden"].as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys, ["library:swatches:loaded/b.svg", "library:swatches:loaded/b.svg (2)"], "no entry is dropped: {r}"); + assert_eq!(ui["color_guide_limit"], "loaded/b.svg (2)", "the same id everywhere"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` and `ui.docking` show a hidden library under the same id, also when another hidden +/// library with the same file name is in only one of the replies (here as the Color Guide's limit). +#[test] +fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { + let (mut app, tx, base) = confined("inspect-docking-same-id"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": y})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.open_panel = Some(format!("library:swatches:{y}")); + app.ui.color_guide_limit = x.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!( + (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + "{r}" + ); + let d = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(d["result"]["flyout"], ui["open_panel"], "{d}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites only the ids of libraries the UI state refers to: a script's report line and +/// a command palette query that look like a library id, with no such library, stay as written, and +/// the query does not take the file name of a real library outside the root. +#[test] +fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { + let (mut app, tx, base) = confined("inspect-lookalike"); + let real = format!("loaded/{}", s(base.join("outside/x/d.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": real})).unwrap(); + app.ui.library_panels = vec![panel]; + let query = format!("library:swatches:loaded/{}", s(base.join("outside/d.svg"))); + app.ui.palette_query = query.clone(); + let text = format!("loaded/{}", s(base.join("outside/c.svg"))); + 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!({})); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/d.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() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let theirs = format!("loaded/{}", s(std::env::temp_dir().join("vc-ui-roots-unconfined").join("b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": theirs})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!((&ui["library_panels"][0]["id"], &ui["color_guide_limit"]), (&json!(theirs), &json!(theirs)), "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key(&format!("library:swatches:{theirs}")), "{r}"); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 595b4729d7..a3266538b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -120,8 +120,11 @@ the suite) keep the agent to the folders you give it: 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/` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other - entries keep their order, and the app's own state is not changed. + 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/`, + `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. - 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