git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

commitb8bba0e6880c9894e58085adb485b9822cef5335
parent8c08b99a21
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 17:28
fix(images): a note's edit no longer re-resolves every embed

Every vault change cleared the image link cache, and the open note's own
autosave comes back as one. Each embed then failed its lookup for a
frame — drawn as its link text — until the pump resolved it again and
asked the editor for a second layout: a flicker and a relayout per save,
and per change to any other note.

Links now resolve afresh only when the vault's set of files changed (a
path new to the index, or one removed) or an image changed — the only
changes that can move what a link points to or what it shows. A changed
image still decodes again.

Shadow, a note embedding an image open, another note appended on disk:
2 presents -> 1 (twice each), the picture shown throughout. Test: the
link survives a note edit and is dropped by an image change.

(The audit's other cce-notes item, unlinked mentions re-reading every
note per change, is real but measured at 12 notes / 36 KB in the live
vault — fractions of a millisecond — and left alone.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/images.rs | 37 ++++++++++++++++++++++++++++++++-----
 src/main.rs   |  9 +++++++--
 2 files changed, 39 insertions(+), 7 deletions(-)

diff --git a/src/images.rs b/src/images.rs
index cd33f22..5235665 100644
--- a/src/images.rs
+++ b/src/images.rs
@@ -144,12 +144,20 @@ impl Images {
         self.0.borrow_mut().links.clear();
     }
 
-    /// Files changed in the vault: links resolve afresh (an image may have
-    /// appeared), and a changed image decodes again. True when an image
-    /// on screen went away.
-    pub fn vault_changed(&self, index: &Index, paths: &[PathBuf]) -> bool {
+    /// Files changed in the vault: a changed image decodes again, and with
+    /// `relink` — the vault's set of files changed (one added or removed),
+    /// or an image did — links resolve afresh, since only then can one
+    /// point somewhere new. True when an image on screen went away.
+    ///
+    /// Until 2026-10-06 every change relinked, and the open note's own
+    /// autosave comes back as one: each embed then failed its lookup for a
+    /// frame (drawn as its link text) and the pump that resolved it again
+    /// asked the editor for a second layout — a flicker per save.
+    pub fn vault_changed(&self, index: &Index, paths: &[PathBuf], relink: bool) -> bool {
         let mut inner = self.0.borrow_mut();
-        inner.links.clear();
+        if relink {
+            inner.links.clear();
+        }
         let mut dropped = false;
         for p in paths {
             let Some(rel) = index.rel(p) else { continue };
@@ -248,6 +256,25 @@ mod tests {
         assert_eq!((d.natural, d.tex), ((40, 20), (80, 40)));
     }
 
+    #[test]
+    fn a_note_edit_keeps_resolved_links_and_an_image_change_drops_them() {
+        let dir = tempfile::tempdir().unwrap();
+        let note = dir.path().join("n.md");
+        std::fs::write(&note, "![[pic.png]]\n").unwrap();
+        image::RgbaImage::new(4, 4).save(dir.path().join("pic.png")).unwrap();
+        let ix = Index::open(dir.path(), false).unwrap();
+        let images = Images::default();
+        assert_eq!(images.lookup("pic.png"), None);
+        images.pump(&ix, Some("n.md"), |_, _| {});
+        assert_eq!(images.0.borrow().links.get("pic.png"), Some(&Some("pic.png".to_string())));
+        // The note's own autosave: no file came or went.
+        images.vault_changed(&ix, &[note.clone()], false);
+        assert!(images.0.borrow().links.contains_key("pic.png"), "an edit to a note dropped its embeds' links");
+        // The picture itself changed (or a file came or went): relink.
+        images.vault_changed(&ix, &[dir.path().join("pic.png")], true);
+        assert!(!images.0.borrow().links.contains_key("pic.png"));
+    }
+
     #[test]
     fn lookups_are_recorded_until_pumped() {
         let images = Images::default();
diff --git a/src/main.rs b/src/main.rs
index 66e7609..7025682 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -611,11 +611,16 @@ impl NotesApp {
 
     fn vault_changed(&mut self, paths: Vec<PathBuf>) {
         let Some(ix) = self.index.as_mut() else { return };
-        ix.apply_changes(&paths);
+        // A path the index has not seen is a file (or folder) that just
+        // appeared; with a removal, the vault's set of files changed — the
+        // only change that can move what a link resolves to.
+        let appeared = paths.iter().any(|p| ix.rel(p).is_some_and(|r| ix.entry(&r).is_none()));
+        let changes = ix.apply_changes(&paths);
         // An image added, changed or removed: links resolve again, and the
         // editor asks again (it only asks while laying a line out).
         let images_touched = paths.iter().any(|p| cce_vault::markdown::is_image(&p.to_string_lossy()));
-        self.images.vault_changed(ix, &paths);
+        let relink = appeared || !changes.removed.is_empty() || images_touched;
+        self.images.vault_changed(ix, &paths, relink);
         if images_touched {
             self.editor.invalidate();
         }