mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 14:46:03 +00:00
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:
+208
-23
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user