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