Auto reload changed files
This commit is contained in:
@@ -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
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user