MCP: hidden library ids name their library in requests and are rewritten only as whole ids

Follows #6608. ui.inspect shows a library loaded from outside the read
roots as loaded/<file name>. A request may now name the library or its
panel by that id 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} reopens the closed panel
instead of floating a new one. The reply to such a request, an error
included, shows the shown id.

ui.inspect rewrites only whole ids, and only in the fields that hold
library and panel ids, so a readable library's id that begins with a
hidden one keeps its path, and status text, the command palette query
and a script's report keep their text. A hidden library does not take
the id of a library loaded without a path.

ui.inspect also leaves out of recorded action steps the path and folder
params, params named like destinationFolder and the entries of a paths
list that lie outside the read roots, also inside a batch step's
commands. It hides a library id holding a path written with either
separator, and checks a folder preference against the roots as written,
without trimming spaces.
This commit is contained in:
dotCooCoo
2026-11-11 11:26:25 -08:00
parent c70d320d64
commit d3a02f617d
3 changed files with 405 additions and 27 deletions
+208 -23
View File
@@ -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/<path>`) that the UI state refers to and whose file the
/// request may not read, each with the id a reply shows instead: `loaded/<file name>`, 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:<kind>:loaded/<path>`) 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:<kind>:loaded/<path>`) 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/<file name> (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<String> = 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<Str
/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's
/// id, in its panel's id (`library:swatches:loaded/<path>`) 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<String> {
let mut out: Option<String> = 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<Item = &mut serde_json::Map<String, Value>> {
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<String, Value>) {
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<String, Value>) {
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<String, Value>) {
}
}
}
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<String, Value>) {
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
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<String>) {
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() {
+183
View File
@@ -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/<file name> (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<String> {
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() {
+14 -4
View File
@@ -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/<file name>` 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/<file name>`,
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/<file name>`,
`loaded/<file name> (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/<file name>"}`
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