Fix #925: linked files changed in another app update when the window comes back

Preferences › File Handling › Update Links only acted when a document
opened, so a linked image edited in another app while VectorCraft stayed
open kept its old pixels until Update Link in the Links panel, even with
Automatically chosen.

When the window comes back to the front, the active document's links
are now checked. With Automatically, changed files are read again; with
Ask When Modified, they are offered for update, once per change of a
file (keyed by path, size and modification time, since two saves can
share a time); with Manually, the Links panel shows them as modified.
Nothing is asked while a dialog is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-10 15:49:08 +07:00
co-authored by Claude Opus 5.5
parent 04eb5e52c8
commit 11beb8fff1
5 changed files with 110 additions and 5 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+89 -1
View File
@@ -2,7 +2,8 @@
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
//! offered for update (`links.update`).
//! offered for update (`links.update`). Files changed while the app was in the background are
//! checked when its window comes back to the front ([`after_focus`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
@@ -74,6 +75,39 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
/// A linked file as last seen: its path, and its size and modification time when it exists.
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
/// The window came back to the front (#925): linked files of the active document changed
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
/// asked while a dialog is open.
pub fn after_focus(app: &mut VectorcraftApp) {
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
return;
}
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
if rows.is_empty() {
return;
}
if app.session.prefs.update_links == "automatically" {
// A failure shows in the status bar, as the Links panel's Update Link does.
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
return;
}
// The file each link reads (one found elsewhere after a move), its size and when it was last
// changed (two changes can share a time on file systems that keep it coarsely).
let stamp = |l: &Value| -> FileStamp {
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
let meta = std::fs::metadata(&path).ok();
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
(path, at)
};
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
ask_update(app, ids(&new));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
@@ -262,4 +296,58 @@ mod tests {
assert!(app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// #925: a linked file changed while the app was in the background follows Update Links when
/// the window comes back to the front.
#[test]
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let pic = dir.join("a.png");
// Each version a different size, so no two share a length and modification time.
let mut version = 0;
let mut change = || {
version += 1;
let mut out = vec![];
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
std::fs::write(&pic, out).unwrap();
};
change();
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
// The window coming to the front asks for the check.
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
app.raw_input_hook(&mut raw);
assert!(app.links_check);
// Ask When Modified (the default): asked once per change.
change();
after_focus(&mut app);
let d = app.ui.dialog.take().expect("asks");
assert_eq!(d.str("__command"), "links.update");
after_focus(&mut app);
assert!(app.ui.dialog.is_none(), "not twice for the same change");
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_some(), "a new change asks again");
super::super::confirm(&mut app).unwrap();
assert_eq!(modified(&mut app), 0);
// Automatically: read again without asking.
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 0);
// Manually: left to the Links panel.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 1);
let _ = std::fs::remove_dir_all(dir);
}
}
+15 -1
View File
@@ -371,6 +371,12 @@ pub struct VectorcraftApp {
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
/// The window came back to the front: check the active document's linked files next frame
/// ([`dialogs::missing_links::after_focus`]).
pub(crate) links_check: bool,
/// The changed linked files already offered for update (path, size and modification time), so
/// coming back to the window doesn't ask about the same change twice.
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@@ -468,6 +474,8 @@ impl VectorcraftApp {
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
links_check: false,
links_asked: Default::default(),
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@@ -881,6 +889,9 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
if std::mem::take(&mut self.links_check) {
dialogs::missing_links::after_focus(self);
}
picks::poll(self, ctx);
background::poll(self);
if !self.background.jobs.is_empty() {
@@ -1014,7 +1025,10 @@ impl VectorcraftApp {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
egui::Event::WindowFocused(true) => {
self.refresh_installed_fonts();
self.links_check = true;
}
_ => {}
}
}
+3 -1
View File
@@ -404,7 +404,9 @@ Missing linked files: `app.open` of a document whose linked images' files can't
folder; `ui.dialog.set {field: "discard", value: true}` then confirm ignores it (the images keep their preview), with
`applyToAll` the rest too. The next missing file is asked about after each answer; `ui.dialog.cancel` stops asking.
Then, with the preference `updateLinks: "askWhenModified"`, modified linked files are offered for update in a
`confirm` dialog whose `ui.dialog.confirm` runs `links.update`.
`confirm` dialog whose `ui.dialog.confirm` runs `links.update`. The same happens when the app's window comes back to
the front and a linked file of the active document changed meanwhile (asked once per change of a file); with
`"automatically"` the files are read again at once, with `"manually"` the Links panel shows them as modified.
Missing fonts: once the missing linked file questions are answered, or at once when there are none, `app.open` of a
document whose type uses fonts that aren't available (`text.missingFonts`) opens the `missingFonts` dialog, one document
+2 -1
View File
@@ -1809,7 +1809,8 @@ the preview instead of the pixels, plus the path relative to the saved file. `do
the linked files again, looking for each at its path, then at its relative path and by name in the document's folder
(so a folder moved with its links still opens): the result lists `missingLinks` (their images show the preview),
`modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are
in `updatedLinks`), each as `{name, path, ids}`. `links.check` reports every link's `status` (`ok`, `modified`,
in `updatedLinks`), each as `{name, path, ids}`. The desktop app also checks the active document's links when its window
comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`,
`missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a
file system (the web), linked images show their previews.