//! 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)>)>>, 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 { 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 { 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)> { 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 { 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> { 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, Option)>, update: bool) -> Result { 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 = self.documents().iter().map(|d| d.uid).collect(); let mut before: HashMap = 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)> = 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 = 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 = 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})), } } }