diff --git a/README.md b/README.md index b8cc7e0..ad430db 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.rs b/src/app.rs index 6aa5488..30bbf2d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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))) } diff --git a/src/app/tests.rs b/src/app/tests.rs index c39c496..04ee105 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -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> { + 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(()) +} diff --git a/src/files.rs b/src/files.rs index 1757644..a075bd3 100644 --- a/src/files.rs +++ b/src/files.rs @@ -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, /// 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, + 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 { + 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, + /// Files changed on disk that hold edits made here and not saved yet. + /// These keep the edits; see [`Loaded::stale`]. + pub conflicts: Vec, +} + +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>, + /// 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>, } impl FileStore { /// The text of `path`, reading it the first time it is asked for. pub fn entry(&mut self, path: &Path) -> &Result { - 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 = 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 { 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> { + 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> { + 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> { let dir = diff --git a/src/nodes.rs b/src/nodes.rs index 115e311..28d4416 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -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 { diff --git a/src/view.rs b/src/view.rs index 232e259..e8bd9ec 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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, /// 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>, @@ -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, });