From dfa6e6ea6096e7fcbf1be2a66a06cafd6aa52d24 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:20:14 -0800 Subject: [PATCH] MCP: ui.menu.list leaves out recent files outside the read roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under --automation-read-root, a control client's ui.menu.list (and ui.contextMenu.list, which shares the listing) left every File › Open Recent Files row in, so the client saw the names of recent files it may not read. file.recentFiles returned their full paths, and file.openRecentN for such a file answered with a refusal naming the path. Now the listing leaves out a recent file outside the read roots, as it already leaves out listed scripts outside them. The other rows keep their ids, so file.openRecent3 still opens the third file. file.recentFiles returns only the files inside the roots, in order, and file.openRecentN for a file outside them ends with "no such recent file", as an empty slot does. The person's own menus and Home screen are unchanged. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/menus/entries.rs | 5 +++ crates/ui-egui/src/menus/run.rs | 6 +-- crates/ui-egui/src/menus/slots.rs | 8 ++++ crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 51 +++++++++++++++++++++++++ docs/control-protocol.md | 3 +- docs/mcp.md | 3 ++ docs/target-app-parity.md | 1 + 8 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 3c308970d..815a00d6f 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -1,6 +1,7 @@ use super::context::context_items; use super::invoke::click_target; use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label}; +use super::slots::{recent_readable, recent_slot}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; use super::{Entry, Item, Serialize, Value, VectorcraftApp}; @@ -133,6 +134,10 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve if *id == crate::scripts_menu::COMMAND && !crate::scripts_menu::readable(p) { continue; } + // The other rows keep their ids: `file.openRecent3` still opens the third file. + if id.starts_with("file.openRecent") && recent_slot(app, id).is_some_and(|path| !recent_readable(path)) { + continue; + } let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 536ad79eb..1223e1e42 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::bar::home_key; use super::invoke::menu_dialog; -use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::slots::{recent_readable, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; use super::state::{checked, selected_image}; use super::ui_commands::UI_COMMANDS; use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom}; @@ -137,7 +137,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(io::hide_exports(app)), "file.cancelOpen" => Ok(crate::opening::cancel_all(app)), "file.openInBackground" => Ok(crate::opening::hide_all(app)), - id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + id if id.starts_with("file.openRecent") => match recent_slot(app, id).filter(|path| recent_readable(path)).cloned() { Some(path) => io::open_reporting(app, &path), None => Err("no such recent file".into()), }, @@ -164,7 +164,7 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(io::recent_files(app))), + "file.recentFiles" => Ok(json!(io::recent_files(app).iter().filter(|path| recent_readable(path)).collect::>())), "file.place" => crate::place::run(app, p), "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs index 2dee19dcf..5bd3bfb00 100644 --- a/crates/ui-egui/src/menus/slots.rs +++ b/crates/ui-egui/src/menus/slots.rs @@ -51,6 +51,14 @@ pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a S io::recent_files(app).get(n.checked_sub(1)?) } +/// May the request being served read recent file `path`? Under an automation read root (a control +/// client's request), a recent file outside the root is left out of `ui.menu.list` and +/// `file.recentFiles`, and its `file.openRecentN` slot answers as an empty one, as a listed script +/// outside the root is left out ([`crate::scripts_menu::readable`]). +pub(super) fn recent_readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + /// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ [ diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 06dcf85bc..a96f13e67 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -137,7 +137,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", ), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"), ( "file.export.svg", "Export As SVG…", diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index c66cc7cd5..5eff145b1 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -304,6 +304,57 @@ fn frames_with_injected_input_are_confined() { let _ = std::fs::remove_dir_all(base); } +/// The enabled File › Open Recent Files rows `ui.menu.list` returns: (id, label). +fn recent_rows(entries: &Value) -> Vec<(String, String)> { + let rows = entries.as_array().map(Vec::as_slice).unwrap_or_default(); + rows.iter() + .filter(|e| e["command"].as_str().is_some_and(|c| c.starts_with("file.openRecent")) && e["enabled"] == true) + .map(|e| (e["command"].as_str().unwrap_or_default().to_string(), e["label"].as_str().unwrap_or_default().to_string())) + .collect() +} + +#[test] +fn recent_files_outside_the_read_root_are_not_listed_or_opened() { + let (mut app, tx, base) = confined("recent"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let rows = |pairs: &[(&str, &str)]| pairs.iter().map(|(id, l)| (id.to_string(), l.to_string())).collect::>(); + + // The person's menu lists all three. + let person = serde_json::to_value(crate::menus::menu_entries(&app)).unwrap(); + assert_eq!(recent_rows(&person), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent2", "b.svg"), ("file.openRecent3", "c.svg")])); + assert_eq!(app.run("file.recentFiles", json!({})).unwrap(), json!([a, b, c])); + + // The client's listing leaves out the second, and the third keeps its id. + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent3", "c.svg")]), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.recentFiles"})); + assert_eq!(r["result"], json!([a, c]), "{r}"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent2"})); + assert_eq!(r["error"], "no such recent file", "the reply names no path: {r}"); + assert!(app.session.documents().is_empty()); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent3"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, [c.clone(), a.clone(), b.clone()]); + + // A file outside the root that the person has open is still not brought forward. + app.run("file.openRecent3", json!({})).unwrap(); + assert_eq!((app.session.documents().len(), &app.ui.recent_files[0]), (2, &b)); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent1"})); + assert_eq!(r["error"], "no such recent file", "{r}"); + + // Rows past Number of Recent Files to Display stay out, and a write root alone lists none. + app.session.prefs.recent_files_count = 2; + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent2", "c.svg")]), "{r}"); + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || serde_json::to_value(crate::menus::menu_entries(&app)).unwrap()); + assert!(recent_rows(&listed).is_empty(), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + /// 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/control-protocol.md b/docs/control-protocol.md index 02cc5c438..96e5baffb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -691,7 +691,8 @@ In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitma `edit.paste*` reads it at once. File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference -lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the +lists (0 hides them all; `file.recentFiles` returns the listed paths; under a read root, `ui.menu.list` and +`file.recentFiles` leave out the files outside it and `file.openRecentN` answers `no such recent file` for one), `file.reveal` shows the document's file in the system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker in the Templates folder (preference `templatesFolder`). Older native files open as " [Converted]" (preference `appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved diff --git a/docs/mcp.md b/docs/mcp.md index 82f2d1b06..3585c08df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -112,6 +112,9 @@ the suite) keep the agent to the folders you give it: export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, library and preset saves to a path, the copies `links.relink {copy}` makes. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). +- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The + other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and + `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 764b7ebe8..e5c43c84a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. + - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).