Files
vectorcraft/crates/engine/src/link_watch.rs
T
dotCooCoo 2c0d09e079 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.
2026-11-09 18:35:41 -08:00

209 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]), the condition the worker signals when it ends, and whether it has.
pub type LinkScan = Arc<(Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>, Condvar, AtomicBool)>;
/// Held by a look's worker: marks the look as ended and wakes [`Session::wait_link_scan`] when
/// dropped, whether the worker returned or unwound.
pub(crate) struct Ended(pub(crate) LinkScan);
impl Drop for Ended {
fn drop(&mut self) {
let (done, finished, ended) = &*self.0;
let _held = done.lock().unwrap_or_else(PoisonError::into_inner);
ended.store(true, Ordering::SeqCst);
finished.notify_all();
}
}
/// Whether a look's worker ended without storing stamps, with `read_ended` reading the flag. The
/// worker stores its stamps and marks the look as ended under the slot's lock, and this holds that
/// lock across both reads, so a look whose worker is storing its stamps never counts as ended without them.
pub(crate) fn ended_without_stamps(scan: &LinkScan, read_ended: impl FnOnce(&AtomicBool) -> bool) -> bool {
let stamps = scan.0.lock().unwrap_or_else(PoisonError::into_inner);
stamps.is_none() && read_ended(&scan.2)
}
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
/// automation may not read it.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
let meta = crate::cmd::fileio::file_metadata(&path)?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running, a script is running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || self.script_key.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
// A confined caller's roots, else what the caller that opened the document may read (the
// desktop frame is unconfined).
let opened_by = st.opened_by.clone();
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let scope = crate::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
// Marks the look as ended when the worker returns or unwinds.
let _ended = Ended(out.clone());
let stamps = scope.run(|| {
crate::file_access::confine(crate::file_access::current().or(opened_by).as_ref(), || {
files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect()
})
});
*out.0.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, it is still running, or a script is
/// running): see [`Session::apply_link_stamps`]. A look whose worker ended without stamps is
/// cleared, also with `None`, so the next one can start.
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
if self.script_key.is_some() {
return None;
}
self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst)));
let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Block until the look in flight has ended (at once when none is running), so that the next
/// [`Session::poll_link_scan`] applies its stamps. A look whose worker stopped without stamps
/// ends too, and the poll then clears it and returns `None`. For tests that drive the look step
/// by step.
pub fn wait_link_scan(&self) {
let Some(scan) = &self.link_scan else { return };
let (done, finished, ended) = &**scan;
let guard = done.lock().unwrap_or_else(PoisonError::into_inner);
drop(finished.wait_while(guard, |stamps| stamps.is_none() && !ended.load(Ordering::SeqCst)).unwrap_or_else(PoisonError::into_inner));
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// A drag is in progress: its next preview or cancel would throw an update away while the new
// stamp stayed recorded. Leave the stamps alone; the first look after the drag sees the change.
if self.in_interaction() {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let roots = crate::file_access::current().or_else(|| self.active().and_then(|d| d.opened_by.clone()));
let r = crate::file_access::confine(roots.as_ref(), || self.execute("links.update", &json!({"ids": ids})))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}