MCP: ui.menu.list leaves out recent files outside the read roots

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.
This commit is contained in:
dotCooCoo
2026-11-11 03:20:14 -08:00
parent 6244ac54bf
commit dfa6e6ea60
8 changed files with 74 additions and 5 deletions
+5
View File
@@ -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<String>, 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(),
+3 -3
View File
@@ -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<Result<
"file.exportInBackground" => 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<Result<
app.ui.recent_files.clear();
Ok(Value::Null)
}
"file.recentFiles" => Ok(json!(io::recent_files(app))),
"file.recentFiles" => Ok(json!(io::recent_files(app).iter().filter(|path| recent_readable(path)).collect::<Vec<_>>())),
"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()) => {
+8
View File
@@ -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] = [
[
+1 -1
View File
@@ -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…",
+51
View File
@@ -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::<Vec<_>>();
// 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]
+2 -1
View File
@@ -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 "<name> [Converted]" (preference
`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved
+3
View File
@@ -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
+1
View File
@@ -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).