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
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
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
+63
View File
@@ -17,6 +17,12 @@ use crate::view::{CanvasView, Clipboard, EditTarget, InsertRequest};
/// How long a status message stays on screen.
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.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Pending {
@@ -39,6 +45,7 @@ pub struct App {
show_about: bool,
show_shortcuts: bool,
title: String,
last_file_check: f64,
}
impl App {
@@ -57,6 +64,7 @@ impl App {
show_about: false,
show_shortcuts: false,
title: String::new(),
last_file_check: 0.0,
};
match open {
Some(path) => app.open_path(&path),
@@ -646,6 +654,52 @@ impl App {
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 -----------------------------------------------------------
fn menu_bar(&mut self, ui: &mut Ui, ctx: &Context) {
@@ -1064,6 +1118,7 @@ impl eframe::App for App {
self.update_title(&ctx);
self.handle_shortcuts(&ctx);
self.handle_dropped_files(&ctx);
self.check_files(&ctx);
// Closing the window with unsaved changes asks first.
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"),
];
/// 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 {
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"));
}
/// 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::ops::Range;
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
/// 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>,
/// Set once the text differs from the file on disk.
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.
#[derive(Default)]
pub struct FileStore {
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 {
/// The text of `path`, reading it the first time it is asked for.
pub fn entry(&mut self, path: &Path) -> &Result<Loaded, String> {
self.files
.entry(path.to_path_buf())
.or_insert_with(|| read(path))
if !self.files.contains_key(path) {
self.stamps.insert(path.to_path_buf(), stamp_of(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
@@ -95,6 +196,7 @@ impl FileStore {
let before = self.files.len();
self.files
.retain(|_, entry| matches!(entry, Ok(loaded) if loaded.dirty));
self.stamps.retain(|path, _| self.files.contains_key(path));
before - self.files.len()
}
@@ -118,6 +220,8 @@ impl FileStore {
match fs::write(&path, &loaded.text) {
Ok(()) => {
loaded.dirty = false;
loaded.stale = false;
self.stamps.insert(path.clone(), stamp_of(&path));
written += 1;
}
Err(err) => errors.push(format!("Could not write {}: {err}", path.display())),
@@ -148,6 +252,7 @@ fn read(path: &Path) -> Result<Loaded, String> {
text,
locked,
dirty: false,
stale: false,
})
}
@@ -320,6 +425,82 @@ mod tests {
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]
fn a_file_that_is_not_text_is_read_but_locked() -> Result<(), Box<dyn std::error::Error>> {
let dir =
+8
View File
@@ -111,7 +111,9 @@ fn render_file(
let palette = ctx.palette;
let path = resolve_path(ctx.base_dir, file);
let kind = file_kind(file);
ctx.files.watch(&path);
let edited = ctx.files.is_edited(&path);
let stale = ctx.files.is_stale(&path);
let mut activated = None;
// 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))
.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 {
+30
View File
@@ -314,6 +314,9 @@ struct FileEdit {
id: String,
/// The file it writes to.
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
/// covers right now; `None` when the editor holds the whole file.
section: Option<Range<usize>>,
@@ -370,6 +373,32 @@ impl CanvasView {
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
/// [`crate::app`], which drive the editor from outside this module.
#[cfg(test)]
@@ -1281,6 +1310,7 @@ impl CanvasView {
self.file_edit = Some(FileEdit {
id: id.clone(),
path: path.clone(),
subpath: subpath.clone(),
section,
buffer,
});