diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 6f007c182d..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -471,6 +471,15 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without /// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is /// never readable under a read root. @@ -480,8 +489,8 @@ fn readable(path: &str) -> bool { /// The loaded library ids (`loaded/`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/`, as a library loaded without a path is -/// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the /// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers /// to). When another id already is or shows that id, the next free /// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. A library @@ -501,11 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator hidden.push((id, name)), _ => { diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 000f0e7532..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -238,6 +238,25 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// Outside Windows a `\` is part of a file name: a concealed library saved at `/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + /// A concealed library keeps the id a client was shown when another library with the same file name, /// whose full id sorts before it, is loaded later: the id still finds the first library. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 79d614b9d0..d0f8c28fad 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); leave_out_unreadable_paths(fields); } - let mut inspected = json!({ + json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), @@ -307,9 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // 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)); - inspected + }) } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -435,7 +433,10 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; - let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path); + if name == path { + return; + } let pair = (format!("loaded/{path}"), format!("loaded/{name}")); if !out.contains(&pair) { out.push(pair); @@ -709,17 +710,22 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { } /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at -/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a -/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path -/// `data/icon.svg`; `./data/icon.svg` does. +/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by +/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and +/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and +/// `../data/icon.svg` do. fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { let absolute = from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - let before = &text[..at]; - !absolute - && !before.ends_with("./") - && !before.ends_with(".\\") - && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) + let mut before = &text[..at]; + while let Some(head) = before.strip_suffix(['/', '\\']) { + let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break }; + before = rest; + if !before.ends_with(['/', '\\']) { + break; + } + } + !absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -1191,12 +1197,7 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => { - let mut docking = crate::panel_docking::inspect(app, ctx); - let ids = unreadable_library_ids(app); - hide_library_ids(&mut docking, &ids); - ok(docking) - } + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), // A request that stops with a panic quoting the status text, after clearing the Color Guide's // limit with `forget`. #[cfg(test)] diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d144ffd953..7f0a1177a7 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -278,14 +278,54 @@ fn another_platforms_absolute_path_is_unreadable() { assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); } -/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a -/// longer path. +/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and +/// stays where it ends a longer path, also one with a `.` or `..` part before it. #[test] fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); hide_under_a_root("relative", &mut reply, &hidden); assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); + let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"}); + hide_under_a_root("relative-dots", &mut reply, &hidden); + assert_eq!( + reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'", + "{reply}" + ); +} + +/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its +/// whole file name. +#[cfg(not(windows))] +#[test] +fn a_sent_path_keeps_a_backslash_in_its_file_name() { + let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); +} + +/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system +/// (`/b.svg`) as `loaded/b.svg`, with its separator. +#[test] +fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let panel = "library:swatches:loaded//b.svg".to_string(); + app.ui.color_guide_limit = "loaded//b.svg".to_string(); + app.ui.open_panel = Some(panel); + let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { + (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); + assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); + assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); } /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded diff --git a/docs/mcp.md b/docs/mcp.md index 0d224f247b..c4c649cb3c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,7 +156,8 @@ the suite) keep the agent to the folders you give it: 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, and outside Windows a path that starts - with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same file name, the libraries take `loaded/`, `loaded/ (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest