//! Changes made to a file outside ElyMark (MARK-08, ยง14.4). use std::{ io, path::{Path, PathBuf}, time::Duration, }; use ely_files::{Fingerprint, Policy, PolicyEngine, SaveState}; use ely_gpui_component::{feedback::Banner, primitives::Severity}; use ely_office::prelude::*; use futures::{StreamExt as _, channel::mpsc}; use gpui::Context; use notify::{RecommendedWatcher, RecursiveMode, Watcher as _}; use crate::desk::Desk; use crate::text::Source; use ely_ui::i18n::text; /// Bursts, such as a save's rename, settle into one look. const SETTLE: Duration = Duration::from_millis(200); /// An atomic replace empties the path this long at most. const GAP: Duration = Duration::from_millis(300); /// What happened to a tab's file on disk. #[derive(Default)] pub enum Outside { #[default] Clear, /// Changed under unsaved text: the disk's version, as read. Changed(Source, Fingerprint), Deleted, /// Changes cannot be followed; why. Unread(String), } /// What ElyMark keeps per tab. pub struct Extra { pub outside: Outside, /// The text as last loaded or saved: the merge base. pub base: String, pub policy: PolicyEngine, /// The text change autosave last heard. pub heard: u64, /// The policy revision an autosave writes. pub writing: Option, /// A look put off while a save runs. pub recheck: bool, /// Its recovery folder's name, once given. pub slot: String, /// The last recovery point's mark, and one in flight. pub pointed: Option, pub pointing: bool, /// A kept tab's mode, place and text came back once. pub restored: bool, /// Disk reads started; only the newest lands. pub reads: u64, } impl Extra { /// Autosave from scratch; a write in flight no longer counts. pub fn restart_autosave(&mut self) { self.policy = PolicyEngine::new(Policy::MarkAutoSave, true); self.writing = None; } } impl Default for Extra { fn default() -> Self { Extra { outside: Outside::Clear, base: String::new(), policy: PolicyEngine::new(Policy::MarkAutoSave, false), heard: 0, writing: None, recheck: false, slot: String::new(), pointed: None, pointing: false, restored: false, reads: 0, } } } /// The file's bytes and the fingerprint they match. fn read_still(path: &Path) -> io::Result<(Fingerprint, Source)> { // A writer mid-way changes the fingerprint; read again. for _ in 0..5 { let before = Fingerprint::of(path)?; let bytes = std::fs::read(path)?; if Fingerprint::of(path)? == before { return Ok((before, Source::decode(bytes)?)); } } Err(io::Error::other("the file kept changing while read")) } impl Desk { /// Watches every tab's folder; a change looks at the tabs. pub fn watch_folders(&mut self, cx: &mut Context) { let mut dirs: Vec = self .core .tabs .iter() .filter_map(|tab| Some(tab.session.path()?.parent()?.to_path_buf())) .collect(); dirs.sort(); dirs.dedup(); if dirs == self.watched { return; } self.watcher = None; self.watched = dirs.clone(); if dirs.is_empty() { return; } match watch(&dirs, cx) { Ok(watcher) => self.watcher = Some(watcher), Err(error) => self.unwatched(error.to_string(), cx), } } /// Watching failed: every file says so and offers a look. fn unwatched(&mut self, why: String, cx: &mut Context) { log::error!("mark: watching files failed: {why}"); let ids: Vec = self.core.tabs.iter().map(|tab| tab.id).collect(); for id in ids { self.set_outside(id, Outside::Unread(why.clone()), cx); } } pub fn look_all(&mut self, cx: &mut Context) { // A write, ours or not, may change what Git sees. self.ask_git(cx); let ids: Vec = self.core.tabs.iter().map(|tab| tab.id).collect(); for id in ids { self.look(id, cx); } } /// Compares tab `id`'s file with its last read or write. pub fn look(&mut self, id: u64, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; }; let Some(path) = tab.session.path().map(Path::to_path_buf) else { return; }; if tab.exporting || tab.session.state() == SaveState::Saving { tab.extra.recheck = true; return; } tab.extra.recheck = false; let ours = tab.session.fingerprint(); match Fingerprint::of(&path) { Ok(now) if Some(now) == ours => { if !matches!(tab.extra.outside, Outside::Changed(..)) { self.set_outside(id, Outside::Clear, cx); } } Ok(_) => self.read_disk(id, path, cx), Err(error) if error.kind() == io::ErrorKind::NotFound => self.missing(id, path, cx), Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx), } } /// Gone: an atomic replace, a rename, or a delete. fn missing(&mut self, id: u64, path: PathBuf, cx: &mut Context) { cx.spawn(async move |desk, cx| { cx.background_executor().timer(GAP).await; desk.update(cx, |desk, cx| desk.still_missing(id, path, cx)) .ok(); }) .detach(); } fn still_missing(&mut self, id: u64, path: PathBuf, cx: &mut Context) { let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else { return; }; if path.exists() { return self.look(id, cx); } let found = match (tab.registry, path.parent()) { (Some(registry), Some(dir)) => self.core.sessions.renamed_in(registry, dir), _ => Ok(None), }; match found { Ok(Some(to)) => { log::info!("mark: {} was renamed outside", path.display()); self.follow(id, to, cx); self.look(id, cx); } Ok(None) => { log::warn!("mark: {} was deleted outside", path.display()); self.set_outside(id, Outside::Deleted, cx); } Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx), } } /// Changed: a clean tab follows, a dirty one asks. fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; }; tab.extra.reads += 1; let number = tab.extra.reads; let reading = path.clone(); cx.spawn(async move |desk, cx| { let read = cx .background_executor() .spawn(async move { read_still(&reading) }) .await; desk.update(cx, |desk, cx| desk.disk_read(id, path, number, read, cx)) .ok(); }) .detach(); } fn disk_read( &mut self, id: u64, path: PathBuf, number: u64, read: io::Result<(Fingerprint, Source)>, cx: &mut Context, ) { let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else { return log::info!("mark: a read for a moved or closed tab was dropped"); }; if tab.extra.reads != number { return log::info!("mark: read {number} of tab {id} lapsed"); } let (fingerprint, disk) = match read { Ok(read) => read, Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx), }; // The disk moved on since the read: look again. if Fingerprint::of(&path).ok() != Some(fingerprint) { log::info!("mark: tab {id}'s file changed while read"); return self.look(id, cx); } let current = tab.doc.lock().expect("the text lock").current.clone(); let kept = disk.text == tab.extra.base; let dirty = tab.dirty(cx); log::info!("mark: {} changed outside; unsaved {dirty}", path.display()); match (disk.text == current, dirty) { // The same text: only the file underneath is new. (true, _) => { self.adopt_disk(id, &disk, fingerprint, cx); } // The text as last saved: the edits stay unsaved. (false, true) if kept => self.keep_edits(id, disk, fingerprint, cx), (false, false) => self.take_disk(id, disk, fingerprint, cx), (false, true) => { let tab = self.tab_mut(id).expect("open"); tab.session.external_modified(); tab.extra.policy.external_modified(); self.set_outside(id, Outside::Changed(disk, fingerprint), cx); // An open comparison shows the newest disk text. if self .comparing .as_ref() .is_some_and(|comparing| comparing.id == id) { self.compare(id, cx); } } } } /// The disk's text replaces the tab's; autosave starts afresh. pub fn take_disk( &mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context, ) { if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) { view.update(cx, |view, cx| view.refresh(disk, cx)); } } /// A new file with the saved text; edits stay. fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; }; tab.session.rebase(fingerprint); tab.doc.lock().expect("the text lock").source = disk; // A conflict stopped autosave, maybe mid-write; it resumes. tab.extra.restart_autosave(); let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf)); if let (Some(registry), Some(path)) = (registry, path) && let Err(error) = self.core.sessions.track(registry, &path) { log::warn!("mark: the replaced file did not register: {error}"); } self.set_outside(id, Outside::Clear, cx); self.save_after_pause(id, cx); } /// The tab and its view take the file as saved. pub(crate) fn adopt_disk( &mut self, id: u64, disk: &Source, fingerprint: Fingerprint, cx: &mut Context, ) -> Option> { let tab = self.tab_mut(id)?; if let Err(error) = tab.session.reload_at(fingerprint) { self.set_outside(id, Outside::Unread(error.to_string()), cx); return None; } tab.extra.base = disk.text.clone(); // The file holds the disk's text: it is saved. let saved = crate::engine::mark(&disk.text); tab.mark = saved.clone(); tab.extra.restart_autosave(); // Its bytes (BOM, line ends) are what saves keep now. tab.doc.lock().expect("the text lock").source = disk.clone(); let (view, registry) = (tab.view.clone(), tab.registry); let path = tab.session.path().map(Path::to_path_buf); // An atomic replace is a new file underneath. if let (Some(registry), Some(path)) = (registry, path) && let Err(error) = self.core.sessions.track(registry, &path) { log::warn!("mark: the replaced file did not register: {error}"); } self.set_outside(id, Outside::Clear, cx); view.update(cx, |view, cx| view.saved(&saved, cx)); Some(view) } pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context) { if let Some(tab) = self.tab_mut(id) { tab.extra.outside = outside; cx.notify(); } } /// The active file's trouble on disk, with its way out. pub fn outside_banner(&self, cx: &mut Context) -> Option { let tab = self.active_tab()?; let (id, name) = (tab.id, tab.name.to_string()); let this = cx.entity(); let (key, why, action, severity) = match &tab.extra.outside { Outside::Clear => return None, Outside::Changed(..) => ( "mark.outside.changed", None, "mark.outside.compare", Severity::Warning, ), Outside::Deleted => ( "mark.outside.deleted", None, "mark.outside.save_as", Severity::Warning, ), Outside::Unread(why) => ( "mark.outside.unread", Some(why.clone()), "mark.outside.refresh", Severity::Danger, ), }; let why = why.unwrap_or_default(); let said = text(cx, key, &[("name", &name), ("why", &why)]); let label = text(cx, action, &[]); let banner = Banner::new("mark-outside", severity, said); Some(banner.action(label, move |_, cx| { this.update(cx, |desk, cx| { match desk.tab(id).map(|tab| &tab.extra.outside) { Some(Outside::Changed(..)) => desk.compare(id, cx), Some(Outside::Deleted) => desk.save_as(id, None, cx), _ => desk.look(id, cx), } }) })) } } /// Watches `dirs`; each settled burst looks at the tabs. fn watch(dirs: &[PathBuf], cx: &mut Context) -> notify::Result { let (sender, mut changes) = mpsc::unbounded(); let mut watcher = notify::recommended_watcher(move |event: notify::Result| { // Reads, ours included, are no change. if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) { return; } let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string())); })?; for dir in dirs { watcher.watch(dir, RecursiveMode::NonRecursive)?; } cx.spawn(async move |desk, cx| { while let Some(first) = changes.next().await { cx.background_executor().timer(SETTLE).await; let mut failed = first.err(); while let Ok(more) = changes.try_recv() { failed = more.err().or(failed); } let looked = desk.update(cx, |desk, cx| match failed { Some(why) => desk.unwatched(why, cx), None => desk.look_all(cx), }); if looked.is_err() { break; } } }) .detach(); log::info!("mark: watching {} folders", dirs.len()); Ok(watcher) } #[cfg(test)] pub(crate) mod tests;