Scripts: File › Scripts › Other Script… runs JavaScript files

File › Scripts › Other Script… picks a .js or .jsx file, shows a warning
that can be turned off in Preferences › General, and runs the script
against the open documents through the `vc` object. The new
`script.run` and `script.stop` commands run and stop a script over MCP.

- A run is one transaction. Each document it changed gets one undo step
  named Run Script. An uncaught error, a limit or a stop puts every
  document, the paint, the clipboard, menu state and preferences back.
- A command that fails inside a run, or whose result is too large to
  reach the script, is undone and throws an error the script can catch.
- Files a command wrote stay on disk and the report names them, also
  when the run is stopped from the quit question.
- While a script runs, other commands that change anything fail with a
  busy error; MCP reports it as JSON-RPC error -32003.
- In the app the run advances one slice per frame with a Stop button in
  the status bar. A run that adds a great many objects asks whether to
  continue; Stop and Escape end it.
- Commands that change state outside the documents, such as prefs.*,
  plugin.* and file.place.queue, are refused to scripts.

Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md,
docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-09 18:35:41 -08:00
parent 25cfa9d3cf
commit 2c0d09e079
77 changed files with 4093 additions and 58 deletions
+31 -2
View File
@@ -9,6 +9,9 @@ use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_engine::cmd::scripts::RUN_EXTS;
use vectorcraft_engine::script::MAX_SOURCE_BYTES;
use vectorcraft_ui_egui::dialogs::script_run::ScriptInbox;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
@@ -47,6 +50,7 @@ struct Host {
runner: eframe::WebRunner,
inbox: Inbox,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
@@ -75,6 +79,7 @@ pub fn start() {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
place_inbox: Arc::default(),
script_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
pointers: SharedPointers::default(),
@@ -120,7 +125,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let stylus = host.pointers.borrow().stylus();
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let services =
services(host.inbox.clone(), host.place_inbox.clone(), host.script_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
@@ -425,12 +431,35 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed) -> Services {
fn services(
inbox: Inbox,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
ctx: egui::Context,
locks: Option<WebLocks>,
stylus: StylusFeed,
) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
let (script_picked, script_ctx) = (script_inbox.clone(), ctx.clone());
Services {
// File › Scripts › Other Script…: the picked file goes to the warning before it runs.
script_async: Some(Box::new(move || {
let inbox = script_picked.clone();
let ctx = script_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let filter = vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", "Scripts");
let Some(file) = rfd::AsyncFileDialog::new().add_filter(filter, RUN_EXTS).pick_file().await else {
return;
};
let bytes = if file.inner().size() > MAX_SOURCE_BYTES as f64 { None } else { Some(file.read().await) };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
script_inbox: Some(script_inbox),
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
let inbox = picked.clone();