Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
424 lines
15 KiB
Rust
424 lines
15 KiB
Rust
//! 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<u64>,
|
|
/// 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<String>,
|
|
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<Self>) {
|
|
let mut dirs: Vec<PathBuf> = 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<Self>) {
|
|
log::error!("mark: watching files failed: {why}");
|
|
let ids: Vec<u64> = 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<Self>) {
|
|
// A write, ours or not, may change what Git sees.
|
|
self.ask_git(cx);
|
|
let ids: Vec<u64> = 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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<Self>,
|
|
) -> Option<gpui::Entity<crate::view::MarkView>> {
|
|
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<Self>) {
|
|
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<Self>) -> Option<Banner> {
|
|
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<Desk>) -> notify::Result<RecommendedWatcher> {
|
|
let (sender, mut changes) = mpsc::unbounded();
|
|
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
|
// 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;
|