Indicate when file has changed on disk

This commit is contained in:
2025-07-09 13:29:22 +02:00
parent 38d26f0028
commit 3a2f058456
8 changed files with 467 additions and 42 deletions

View File

@ -1,30 +1,77 @@
use std::{
cmp::Ordering,
fmt::{self, Display},
fs::{self, File},
io::Write,
ops::{Div as _, Sub as _},
path::{Path, PathBuf},
str::FromStr,
sync::mpsc,
};
use chrono::{DateTime, Local};
use egui::{
Align, Button, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, Widget as _, vec2,
Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2,
Widget as _, vec2,
};
use eyre::eyre;
use notify::{EventKind, Watcher};
use crate::{
app::Jobs,
custom_code_block::{MdItem, iter_lines_and_code_blocks},
handwriting::{self, Handwriting, HandwritingStyle},
preferences::Preferences,
text_editor::MdTextEdit,
util::{file_mtime, log_error},
};
#[derive(serde::Deserialize, serde::Serialize)]
pub struct FileEditor {
title: String,
pub path: Option<PathBuf>,
path: Option<PathBuf>,
pub buffer: Vec<BufferItem>,
/// Whether the file has been edited since it was laste saved to disk.
pub is_dirty: bool,
pub file_mtime: Option<DateTime<Local>>,
pub buffer_mtime: DateTime<Local>,
// TODO: instantiate these on load
#[serde(skip)]
inner: Option<Inner>,
/// Whether the file has been edited since it was last saved to disk.
is_dirty: bool,
}
struct Inner {
file_events: mpsc::Receiver<FileEvent>,
file_events_tx: mpsc::Sender<FileEvent>,
_file_watcher: notify::RecommendedWatcher,
}
#[derive(Debug)]
pub enum SaveStatus {
/// The contents of the buffer is the same as on disk.
Synced,
/// The contents exits only in memory and has never been saved to disk.
NoFile,
/// The buffer has been edited but not saved to disk.
FileOutdated,
/// The contents on disk has changes that are newer than the buffer.
BufferOutdated,
/// The contents on disk and in the buffer has diverged.
Desynced,
}
#[derive(Debug)]
enum FileEvent {
NewFileMTime(DateTime<Local>),
NewBufferMTime(DateTime<Local>),
}
#[derive(serde::Deserialize, serde::Serialize)]
@ -40,18 +87,24 @@ impl FileEditor {
title: title.into(),
path: None,
buffer,
file_mtime: None,
buffer_mtime: Local::now(),
is_dirty: false,
inner: None,
}
}
pub fn from_file(file_path: PathBuf, contents: &str) -> Self {
pub fn from_file(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self {
let file_title = file_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| String::from("untitled.md"));
Self {
title: file_title,
path: Some(file_path),
file_mtime: Some(mtime),
buffer_mtime: mtime,
..FileEditor::from(contents)
}
}
@ -64,7 +117,46 @@ impl FileEditor {
self.path.as_deref()
}
pub fn save_status(&self) -> SaveStatus {
let Some(file_mtime) = self.file_mtime else {
return SaveStatus::NoFile;
};
let buffer_is_newer = self.buffer_mtime > file_mtime;
let file_is_newer = self.buffer_mtime < file_mtime;
if buffer_is_newer || (file_is_newer && self.is_dirty) {
SaveStatus::Desynced
} else if file_is_newer {
SaveStatus::BufferOutdated
} else if self.is_dirty {
SaveStatus::FileOutdated
} else {
SaveStatus::Synced
}
}
pub fn show(&mut self, ui: &mut Ui, preferences: &Preferences) {
if let Some(path) = &self.path
&& self.inner.is_none()
{
self.inner = Some(spawn_file_watcher(path));
}
if let Some(inner) = &mut self.inner {
while let Ok(event) = inner.file_events.try_recv() {
match dbg!(event) {
FileEvent::NewFileMTime(mtime) => {
self.file_mtime = Some(mtime);
}
FileEvent::NewBufferMTime(mtime) => {
self.buffer_mtime = mtime;
self.file_mtime = Some(mtime);
}
}
}
}
ui.vertical_centered_justified(|ui| {
ui.heading(&self.title);
@ -243,6 +335,38 @@ impl FileEditor {
self.title = title.to_string_lossy().to_string();
self.path = Some(new_path);
}
pub fn set_dirty(&mut self, value: bool) {
self.is_dirty = value;
}
pub fn save(&mut self, ctx: &Context, jobs: &mut Jobs) {
let Some(file_path) = self.path.clone() else {
log::info!("Can't save {}, no path set.", self.title);
return;
};
self.is_dirty = false;
let text = self.to_string();
let inner = self
.inner
.get_or_insert_with(|| spawn_file_watcher(&file_path));
let file_event_tx = inner.file_events_tx.clone();
jobs.start(ctx, move || {
log_error(eyre!("Failed to save file {file_path:?}"), || {
let mut file = fs::File::create(file_path)?;
file.write_all(text.as_bytes())?;
let mtime = file_mtime(&file)?;
let _ = file_event_tx.send(FileEvent::NewBufferMTime(mtime));
Ok(())
});
None
});
}
}
impl Display for BufferItem {
@ -314,6 +438,40 @@ impl From<&str> for FileEditor {
}
}
fn spawn_file_watcher(p: &Path) -> Inner {
let (tx, rx) = mpsc::channel();
let path = p.to_owned();
let events_tx = tx.clone();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
log_error(eyre!("watch {path:?} error"), || {
match event?.kind {
EventKind::Create(..) | EventKind::Modify(..) | EventKind::Remove(..) => {}
EventKind::Access(..) | EventKind::Any | EventKind::Other => return Ok(()),
}
let file = File::open(&path)?;
let mtime = file_mtime(&file)?;
let _ = events_tx.send(FileEvent::NewFileMTime(mtime.into()));
Ok(())
});
})
.unwrap();
if let Err(e) = watcher.watch(p, notify::RecursiveMode::NonRecursive) {
log::error!("Failed to watch {p:?}: {e}");
};
Inner {
file_events: rx,
file_events_tx: tx,
_file_watcher: watcher,
}
}
#[cfg(test)]
mod test {