Auto reload changed files

This commit is contained in:
2026-09-11 08:43:33 +02:00
parent 2bab19a1ae
commit d574680321
6 changed files with 353 additions and 3 deletions
+7
View File
@@ -51,6 +51,13 @@ Group `background` images are drawn with the fitting their `backgroundStyle`
asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the
canvas, so a canvas plus its files stays portable. canvas, so a canvas plus its files stays portable.
**File nodes follow their files**: the files behind file nodes are watched
while the canvas is open, so a note written in another program, an image
exported again, or a file that only now appears shows up on the card by itself.
A file edited here is the one exception: it keeps the edit, and its card is
marked to say the file changed underneath it, so nothing is written over
without you saying so.
**A display text for link nodes**: name a link in the inspector and the canvas **A display text for link nodes**: name a link in the inspector and the canvas
shows the name instead of the address, with the full URL in its tooltip. It is shows the name instead of the address, with the full URL in its tooltip. It is
stored as a `label` attribute — the name groups and edges already use — so a stored as a `label` attribute — the name groups and edges already use — so a
+63
View File
@@ -17,6 +17,12 @@ use crate::view::{CanvasView, Clipboard, EditTarget, InsertRequest};
/// How long a status message stays on screen. /// How long a status message stays on screen.
const STATUS_SECONDS: f64 = 5.0; const STATUS_SECONDS: f64 = 5.0;
/// How often the files behind file nodes are looked at to see whether
/// something outside this editor changed them. Often enough that a file saved
/// in another program appears to update by itself, rarely enough that it is a
/// handful of `stat` calls a second and no more.
const FILE_CHECK_SECONDS: f64 = 1.0;
/// An action waiting for the user to decide what to do about unsaved changes. /// An action waiting for the user to decide what to do about unsaved changes.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum Pending { enum Pending {
@@ -39,6 +45,7 @@ pub struct App {
show_about: bool, show_about: bool,
show_shortcuts: bool, show_shortcuts: bool,
title: String, title: String,
last_file_check: f64,
} }
impl App { impl App {
@@ -57,6 +64,7 @@ impl App {
show_about: false, show_about: false,
show_shortcuts: false, show_shortcuts: false,
title: String::new(), title: String::new(),
last_file_check: 0.0,
}; };
match open { match open {
Some(path) => app.open_path(&path), Some(path) => app.open_path(&path),
@@ -646,6 +654,52 @@ impl App {
self.set_status(message, ctx); self.set_status(message, ctx);
} }
/// Looks for changes made to the files behind file nodes by something
/// other than this editor, every [`FILE_CHECK_SECONDS`].
///
/// A file with no unsaved edit here is read again and the node showing it
/// follows along, which is what a file node is for. A file edited here is
/// left alone and marked on its card instead, because the edit is the one
/// thing that cannot be read back from disk.
fn check_files(&mut self, ctx: &Context) {
if !self.view.files.is_watching() {
return;
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64(FILE_CHECK_SECONDS));
let now = ctx.input(|i| i.time);
if now - self.last_file_check < FILE_CHECK_SECONDS {
return;
}
self.last_file_check = now;
let changes = self.view.files.check_disk();
if changes.is_empty() {
return;
}
for path in &changes.reloaded {
ctx.forget_image(&crate::nodes::file_uri(path));
}
self.view.reload_editor(&changes.reloaded);
let mut parts = Vec::new();
if !changes.reloaded.is_empty() {
parts.push(match changes.reloaded.len() {
1 => format!("Reloaded {}", name_of(&changes.reloaded[0])),
n => format!("Reloaded {n} files changed on disk"),
});
}
if !changes.conflicts.is_empty() {
parts.push(match changes.conflicts.len() {
1 => format!(
"{} changed on disk, kept the edit here",
name_of(&changes.conflicts[0])
),
n => format!("{n} files changed on disk, kept the edits here"),
});
}
self.set_status(parts.join(" \u{2014} "), ctx);
}
// -- chrome ----------------------------------------------------------- // -- chrome -----------------------------------------------------------
fn menu_bar(&mut self, ui: &mut Ui, ctx: &Context) { fn menu_bar(&mut self, ui: &mut Ui, ctx: &Context) {
@@ -1064,6 +1118,7 @@ impl eframe::App for App {
self.update_title(&ctx); self.update_title(&ctx);
self.handle_shortcuts(&ctx); self.handle_shortcuts(&ctx);
self.handle_dropped_files(&ctx); self.handle_dropped_files(&ctx);
self.check_files(&ctx);
// Closing the window with unsaved changes asks first. // Closing the window with unsaved changes asks first.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting { if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
@@ -1145,6 +1200,14 @@ const SHORTCUTS: &[(&str, &str)] = &[
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"), ("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),
]; ];
/// The file name of `path` for a status message, falling back to the whole
/// path for the odd one that has none.
fn name_of(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool { fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool {
ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key))) ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
} }
+61
View File
@@ -91,3 +91,64 @@ fn close_and_fit_sizes_the_card_and_closes_the_editor() {
); );
assert_eq!(app.doc.undo_label(), Some("Fit to text")); assert_eq!(app.doc.undo_label(), Some("Fit to text"));
} }
/// A file node follows its file when something else writes it.
#[test]
fn a_card_follows_a_file_changed_outside_the_editor() -> Result<(), Box<dyn std::error::Error>> {
let dir =
std::env::temp_dir().join(format!("jsoncanvas-outside-{}", crate::model::random_id()));
std::fs::create_dir_all(&dir)?;
let path = dir.join("note.md");
std::fs::write(&path, "before the change\n")?;
let mut canvas = Canvas::default();
canvas.nodes.push(Node::new(
"f",
NodeKind::File {
file: path.display().to_string(),
subpath: None,
},
0,
0,
400,
300,
));
let mut harness = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::new(cc, None));
harness.state_mut().doc = Document::new();
harness.state_mut().doc.canvas = canvas;
harness.run();
assert!(harness.query_by_label("before the change").is_some());
// Another program writes the file while the canvas is open.
std::fs::write(&path, "after the change\n")?;
// The check is on a timer; this is the same as waiting for it to come up.
harness.state_mut().last_file_check = f64::MIN;
harness.run();
assert!(harness.query_by_label("after the change").is_some());
assert!(
harness.query_by_label("before the change").is_none(),
"the card kept showing the text the file no longer holds"
);
// With an edit made here the card keeps it, and says the file moved on.
harness
.state_mut()
.view
.files
.set(&path, "edited here\n".to_owned());
std::fs::write(&path, "changed again, elsewhere\n")?;
harness.state_mut().last_file_check = f64::MIN;
harness.run();
assert!(harness.query_by_label("edited here").is_some());
assert!(
harness.query_by_label("\u{26a0}").is_some(),
"the card said nothing about the file changing under the edit"
);
std::fs::remove_dir_all(&dir)?;
Ok(())
}
+184 -3
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::ops::Range; use std::ops::Range;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Only this much of a file is read. Beyond it the tail is not in memory, so /// Only this much of a file is read. Beyond it the tail is not in memory, so
/// writing the buffer back would cut the file short — such a file is shown but /// writing the buffer back would cut the file short — such a file is shown but
@@ -43,20 +44,120 @@ pub struct Loaded {
pub locked: Option<Locked>, pub locked: Option<Locked>,
/// Set once the text differs from the file on disk. /// Set once the text differs from the file on disk.
pub dirty: bool, pub dirty: bool,
/// Set when the underlying file changed on disk.
pub stale: bool,
}
/// What a file looked like on disk when it was last read or written here.
///
/// Size as well as time, because a file written twice within the resolution
/// of the file system's clock is otherwise indistinguishable from one that
/// was not touched at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stamp {
modified: Option<SystemTime>,
len: u64,
}
/// The stamp of `path`, or `None` when it cannot be looked at — which is what
/// a file that is not there reads as, so that deleting one counts as a change
/// and creating it counts as another.
fn stamp_of(path: &Path) -> Option<Stamp> {
let data = fs::metadata(path).ok()?;
Some(Stamp {
modified: data.modified().ok(),
len: data.len(),
})
}
/// What [`FileStore::check_disk`] found changed since the last look.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Changes {
/// Files read again, because nothing here would have been lost by it.
/// Includes files watched but never read, such as the images of file
/// nodes, whose paths are reported so their cached picture can be dropped.
pub reloaded: Vec<PathBuf>,
/// Files changed on disk that hold edits made here and not saved yet.
/// These keep the edits; see [`Loaded::stale`].
pub conflicts: Vec<PathBuf>,
}
impl Changes {
pub fn is_empty(&self) -> bool {
self.reloaded.is_empty() && self.conflicts.is_empty()
}
} }
/// The text of every file the canvas has looked at. /// The text of every file the canvas has looked at.
#[derive(Default)] #[derive(Default)]
pub struct FileStore { pub struct FileStore {
files: HashMap<PathBuf, Result<Loaded, String>>, files: HashMap<PathBuf, Result<Loaded, String>>,
/// Every path being watched for outside changes, with what it looked like
/// when it was last read here. Holds the files in `files` and also the
/// ones only shown, such as images, which are never read into memory.
stamps: HashMap<PathBuf, Option<Stamp>>,
} }
impl FileStore { impl FileStore {
/// The text of `path`, reading it the first time it is asked for. /// The text of `path`, reading it the first time it is asked for.
pub fn entry(&mut self, path: &Path) -> &Result<Loaded, String> { pub fn entry(&mut self, path: &Path) -> &Result<Loaded, String> {
self.files if !self.files.contains_key(path) {
.entry(path.to_path_buf()) self.stamps.insert(path.to_path_buf(), stamp_of(path));
.or_insert_with(|| read(path)) self.files.insert(path.to_path_buf(), read(path));
}
&self.files[path]
}
/// Watches `path` for outside changes without reading it. For the files a
/// node shows but never loads, such as images.
pub fn watch(&mut self, path: &Path) {
if !self.stamps.contains_key(path) {
self.stamps.insert(path.to_path_buf(), stamp_of(path));
}
}
/// Whether any file is being watched, which is whether a file node has
/// been drawn at all.
pub fn is_watching(&self) -> bool {
!self.stamps.is_empty()
}
/// Whether `path` changed on disk under an edit made here. Never reads.
pub fn is_stale(&self, path: &Path) -> bool {
matches!(self.files.get(path), Some(Ok(loaded)) if loaded.stale)
}
/// Looks at every watched file to see whether something outside this
/// editor changed it.
///
/// A file with no edits here is simply read again: what was on screen was
/// the file, and now it is the file again. One with edits is left as it
/// is and marked [`Loaded::stale`], because reading over it would throw
/// away work this editor cannot get back.
pub fn check_disk(&mut self) -> Changes {
let mut changes = Changes::default();
let mut paths: Vec<PathBuf> = self.stamps.keys().cloned().collect();
paths.sort();
for path in paths {
let now = stamp_of(&path);
if self.stamps.get(&path).is_some_and(|was| *was == now) {
continue;
}
self.stamps.insert(path.clone(), now);
match self.files.get_mut(&path) {
Some(Ok(loaded)) if loaded.dirty => {
loaded.stale = true;
changes.conflicts.push(path);
}
_ => {
if self.files.contains_key(&path) {
self.files.insert(path.clone(), read(&path));
}
changes.reloaded.push(path);
}
}
}
changes
} }
/// Replaces the text of a file that has already been read, marking it as /// Replaces the text of a file that has already been read, marking it as
@@ -95,6 +196,7 @@ impl FileStore {
let before = self.files.len(); let before = self.files.len();
self.files self.files
.retain(|_, entry| matches!(entry, Ok(loaded) if loaded.dirty)); .retain(|_, entry| matches!(entry, Ok(loaded) if loaded.dirty));
self.stamps.retain(|path, _| self.files.contains_key(path));
before - self.files.len() before - self.files.len()
} }
@@ -118,6 +220,8 @@ impl FileStore {
match fs::write(&path, &loaded.text) { match fs::write(&path, &loaded.text) {
Ok(()) => { Ok(()) => {
loaded.dirty = false; loaded.dirty = false;
loaded.stale = false;
self.stamps.insert(path.clone(), stamp_of(&path));
written += 1; written += 1;
} }
Err(err) => errors.push(format!("Could not write {}: {err}", path.display())), Err(err) => errors.push(format!("Could not write {}: {err}", path.display())),
@@ -148,6 +252,7 @@ fn read(path: &Path) -> Result<Loaded, String> {
text, text,
locked, locked,
dirty: false, dirty: false,
stale: false,
}) })
} }
@@ -320,6 +425,82 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn a_change_made_outside_is_read_again_unless_it_would_lose_an_edit()
-> Result<(), Box<dyn std::error::Error>> {
let dir =
std::env::temp_dir().join(format!("jsoncanvas-files-{}", crate::model::random_id()));
fs::create_dir_all(&dir)?;
let path = dir.join("note.md");
fs::write(&path, "# Note\n")?;
let mut store = FileStore::default();
store.entry(&path);
assert!(store.check_disk().is_empty());
// Another program writes the file: nothing here would be lost, so the
// new text is taken.
fs::write(&path, "# Note\nfrom elsewhere\n")?;
let changes = store.check_disk();
assert_eq!(changes.reloaded, vec![path.clone()]);
assert!(changes.conflicts.is_empty());
assert_eq!(
store.entry(&path).as_ref().unwrap().text,
"# Note\nfrom elsewhere\n"
);
// And the same change is not reported a second time.
assert!(store.check_disk().is_empty());
// With an edit here, the edit stays and the file is marked instead.
store.set(&path, "# Note\nmine\n".to_owned());
fs::write(&path, "# Note\nfrom elsewhere, later\n")?;
let changes = store.check_disk();
assert_eq!(changes.conflicts, vec![path.clone()]);
assert!(changes.reloaded.is_empty());
assert!(store.is_stale(&path));
assert_eq!(store.entry(&path).as_ref().unwrap().text, "# Note\nmine\n");
// Saving writes the edit out and settles the matter.
assert_eq!(store.save_all(), (1, Vec::new()));
assert!(!store.is_stale(&path));
assert!(
store.check_disk().is_empty(),
"its own write came back as a change"
);
fs::remove_dir_all(&dir)?;
Ok(())
}
#[test]
fn a_watched_file_is_followed_without_being_read() -> Result<(), Box<dyn std::error::Error>> {
let dir =
std::env::temp_dir().join(format!("jsoncanvas-files-{}", crate::model::random_id()));
fs::create_dir_all(&dir)?;
let path = dir.join("picture.png");
let mut store = FileStore::default();
// A node can point at a file that is not there yet.
store.watch(&path);
assert!(store.check_disk().is_empty());
fs::write(&path, [0x89, b'P', b'N', b'G'])?;
assert_eq!(store.check_disk().reloaded, vec![path.clone()]);
// The file was never read into the store, only followed.
assert!(!store.is_edited(&path));
fs::write(&path, [0x89, b'P', b'N', b'G', 0x00])?;
assert_eq!(store.check_disk().reloaded, vec![path.clone()]);
// Losing the file is a change as much as gaining one.
fs::remove_file(&path)?;
assert_eq!(store.check_disk().reloaded, vec![path.clone()]);
assert!(store.check_disk().is_empty());
fs::remove_dir_all(&dir)?;
Ok(())
}
#[test] #[test]
fn a_file_that_is_not_text_is_read_but_locked() -> Result<(), Box<dyn std::error::Error>> { fn a_file_that_is_not_text_is_read_but_locked() -> Result<(), Box<dyn std::error::Error>> {
let dir = let dir =
+8
View File
@@ -111,7 +111,9 @@ fn render_file(
let palette = ctx.palette; let palette = ctx.palette;
let path = resolve_path(ctx.base_dir, file); let path = resolve_path(ctx.base_dir, file);
let kind = file_kind(file); let kind = file_kind(file);
ctx.files.watch(&path);
let edited = ctx.files.is_edited(&path); let edited = ctx.files.is_edited(&path);
let stale = ctx.files.is_stale(&path);
let mut activated = None; let mut activated = None;
// Header: the file name, plus the subpath when the node points inside a file. // Header: the file name, plus the subpath when the node points inside a file.
@@ -143,6 +145,12 @@ fn render_file(
ui.label(RichText::new("\u{2022}").size(13.0).color(palette.accent)) ui.label(RichText::new("\u{2022}").size(13.0).color(palette.accent))
.on_hover_text("Edited here \u{2014} save the canvas to write it to the file"); .on_hover_text("Edited here \u{2014} save the canvas to write it to the file");
} }
if stale {
ui.label(RichText::new("\u{26a0}").size(12.0).color(palette.danger))
.on_hover_text(
"Changed on disk since it was edited here \u{2014} saving the canvas writes over that change",
);
}
}); });
if ctx.scale < 0.4 { if ctx.scale < 0.4 {
+30
View File
@@ -314,6 +314,9 @@ struct FileEdit {
id: String, id: String,
/// The file it writes to. /// The file it writes to.
path: PathBuf, path: PathBuf,
/// The section of the file the node points at, kept so the buffer can be
/// taken from the file again when it changes on disk.
subpath: Option<String>,
/// For a node pointing at one section of the file, the lines that section /// For a node pointing at one section of the file, the lines that section
/// covers right now; `None` when the editor holds the whole file. /// covers right now; `None` when the editor holds the whole file.
section: Option<Range<usize>>, section: Option<Range<usize>>,
@@ -370,6 +373,32 @@ impl CanvasView {
self.file_edit = None; self.file_edit = None;
} }
/// Takes the editor's buffer from the file again, for a file that was
/// read anew because it changed on disk.
///
/// Only files with no edits here are read again, so nothing typed into
/// the editor can be lost this way. A file that no longer holds the
/// section the node points at closes the editor, as opening one on it
/// would have been refused.
pub fn reload_editor(&mut self, reloaded: &[PathBuf]) {
let Some(edit) = self.file_edit.as_ref() else {
return;
};
if !reloaded.contains(&edit.path) {
return;
}
let (path, subpath) = (edit.path.clone(), edit.subpath.clone());
match self.readable_part(&path, subpath.as_deref()) {
Some((section, buffer)) => {
if let Some(edit) = self.file_edit.as_mut() {
edit.section = section;
edit.buffer = buffer;
}
}
None => self.stop_editing(),
}
}
/// The node the editor is open on, if any. For the tests in /// The node the editor is open on, if any. For the tests in
/// [`crate::app`], which drive the editor from outside this module. /// [`crate::app`], which drive the editor from outside this module.
#[cfg(test)] #[cfg(test)]
@@ -1281,6 +1310,7 @@ impl CanvasView {
self.file_edit = Some(FileEdit { self.file_edit = Some(FileEdit {
id: id.clone(), id: id.clone(),
path: path.clone(), path: path.clone(),
subpath: subpath.clone(),
section, section,
buffer, buffer,
}); });