//! The documents' files: opening, saving, daily backups, reloading what //! changed outside cumulus, read-only documents, and where you left off //! (DESIGN.md, "Files and saving"). //! //! - A change is saved about a second after it (`autosave`; off, `:w` saves, //! as in vim). Undoing back to what's on disk counts as no change. //! - Every second, each file's [stamp](crate::storage::stamp) is checked. A //! file changed outside cumulus is read again, as an undo step (`u` //! brings back what was on screen). If there were unsaved changes too, //! cumulus asks which to keep; if the file has a mistake in it (a hand //! edit in progress), autosave waits until it's fixed. //! - The day's first save copies the file to the backups first. //! - A document another cumulus has open is read-only here, until that one //! lets go of it. use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use thiserror::Error; use super::App; use super::action::{DocAction, Keep, Message, SaveWhich, Tone}; use super::buffer::Buffer; use super::documents::{Access, BufferId, DocFile, Documents, Open, Trouble}; use super::mode::Mode; use super::popup::Popup; use crate::config::paths::Paths; use crate::model::{CanvasPos, DocUid, Document, NodeId, Title}; use crate::storage::backup::{self, Day}; use crate::storage::document_file::{self, LoadError}; use crate::storage::lock::Lock; use crate::storage::memory::LeftOff; use crate::storage::names::{self, file_name, title_from_file}; use crate::storage::stamp::Stamp; use crate::storage::write_atomically; use crate::view::canvas::{Camera, Zoom}; /// How long autosave waits after the latest change. const AUTOSAVE_AFTER: Duration = Duration::from_secs(1); /// How often the files are checked for changes made outside cumulus. const WATCH_EVERY: Duration = Duration::from_secs(1); /// A document ready to open: read from its file, or new. #[derive(Debug)] pub struct Loaded { pub doc: Document, /// Where its file is, in full. pub path: PathBuf, /// The file as it was read; `None` for a new document, whose file is /// written at its first change. pub stamp: Option, } /// Why a document couldn't be opened or made. #[derive(Debug, Error)] pub enum OpenError { #[error("{}: {error}", .path.display())] Read { path: PathBuf, error: io::Error }, #[error("{}: {error}", .path.display())] Load { path: PathBuf, error: LoadError }, #[error("{}: can't write it: {error}", .path.display())] Write { path: PathBuf, error: io::Error }, } impl OpenError { /// The error with just the file's name, for the status bar. fn short(&self) -> String { let (path, error) = match self { OpenError::Read { path, error } => (path, error.to_string()), OpenError::Load { path, error } => (path, error.to_string()), OpenError::Write { path, error } => (path, format!("can't write it: {error}")), }; format!("{}: {error}", short_name(path)) } } /// Reads the document at `path`. With no file there, it's a new document /// named after the file (written once something is added to it). pub fn load(path: &Path) -> Result { let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); let stamp = Stamp::of(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?; if stamp.is_none() { return Ok(Loaded { doc: Document::new(title_from_file(&path)), path, stamp }); } let text = std::fs::read_to_string(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?; let doc = document_file::parse(&text).map_err(|error| OpenError::Load { path: path.clone(), error })?; Ok(Loaded { doc, path, stamp }) } /// Makes a new document at `path` and writes it right away (`cumulus new`, /// `:new`). pub fn create(path: &Path, title: Title) -> Result { let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); let doc = Document::new(title); let write = |error: io::Error| OpenError::Write { path: path.clone(), error }; let text = document_file::write(&doc).map_err(|error| write(io::Error::other(error)))?; write_atomically(&path, &text).map_err(write)?; let stamp = Stamp::of(&path).map_err(write)?; Ok(Loaded { doc, path, stamp }) } /// The first document, which starts the list of open ones. pub(super) fn open_first(loaded: Loaded, paths: &Paths) -> (Documents, Option) { let (buffer, file, said) = opened(loaded, paths); (Documents::new(buffer, file), said) } /// A loaded document as a buffer and its file, with its lock taken if it's /// free, and what to say about it. fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option) { let Loaded { doc, path, stamp } = loaded; let name = short_name(&path); let access = access(paths, doc.uid()); let read_only = matches!(access, Access::ReadOnly); let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here"))); let said = said.or_else(|| { let shown = shown(&path, &paths.home); stamp .is_none() .then(|| Message::info(format!("a new document: it's saved as {shown} once you add something"))) }); let buffer = Buffer::new(doc); let saved = buffer.history().state(); (buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None }, said) } /// Takes a document's lock, if it's free. fn access(paths: &Paths, uid: DocUid) -> Access { match Lock::take(&paths.locks(), uid) { Ok(Some(lock)) => Access::Locked(lock), Ok(None) => Access::ReadOnly, // Without a lock file (the data folder can't be written), the // document can still be edited. Err(_) => Access::Unlocked, } } /// Where a file is, for a message: from `~` when it's in the home folder. fn shown(path: &Path, home: &Path) -> String { match path.strip_prefix(home) { Ok(rest) => { let parts: Vec = rest.iter().map(|part| part.to_string_lossy().into_owned()).collect(); format!("~/{}", parts.join("/")) } Err(_) => path.display().to_string(), } } /// A file's name, for messages: `garden-plans.cum`. fn short_name(path: &Path) -> String { path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned()) } /// A zoom by the name the memory keeps. fn zoom_named(name: &str) -> Zoom { [Zoom::Normal, Zoom::Far, Zoom::Overview] .into_iter() .find(|zoom| zoom.name().eq_ignore_ascii_case(name)) .unwrap_or_default() } impl App { /// Opens another document (or finds it open already: the same file, or /// the same document under another path). Returns it and what to say. pub(super) fn add_document(&mut self, loaded: Loaded) -> (BufferId, Option) { let docs = &self.shared.docs; if let Some(id) = docs.find_path(&loaded.path).or_else(|| docs.find_uid(loaded.doc.uid())) { return (id, None); } let (buffer, file, said) = opened(loaded, &self.paths); let id = self.shared.docs.add(buffer, file); self.came_back_to(id); (id, said) } /// A document was opened: back to where you left off in it, and it's /// the most recent document now. pub(super) fn came_back_to(&mut self, id: BufferId) { let open = self.shared.docs.get_mut(id); let uid = open.buffer.doc().uid().to_string(); if let Some(place) = self.memory.places.get(&uid) { let cursor = NodeId::new(place.cursor); if open.buffer.doc().contains(cursor) { open.buffer.apply(DocAction::MoveCursor { to: cursor, jump: false }); } open.buffer.keep_marks(place.marks.iter().map(|(&name, &id)| (name, NodeId::new(id)))); open.camera = Some(Camera { origin: CanvasPos::new(place.x, place.y), zoom: zoom_named(&place.zoom) }); } let (path, title) = (open.file.path.clone(), open.name()); self.memory.opened(&path, uid, title); } /// Keeps each pane's camera with its document: the focused pane's last, /// so it's the one remembered. pub(super) fn keep_cameras(&mut self) { let focused = self.panes.focused(); let mut cameras: Vec<(bool, BufferId, Camera)> = self .panes .iter_mut() .filter_map(|(id, pane)| Some((id == focused, pane.buffer, pane.view.camera()?))) .collect(); cameras.sort_by_key(|(focused, _, _)| *focused); for (_, buffer, camera) in cameras { self.shared.docs.get_mut(buffer).camera = Some(camera); } } /// Notes where you are in a document, for next time. fn note_place(&mut self, id: BufferId) { let open = self.shared.docs.get(id); let camera = open.camera.unwrap_or_default(); let place = LeftOff { cursor: open.buffer.cursor().get(), zoom: camera.zoom.name().to_lowercase(), x: camera.origin.x, y: camera.origin.y, marks: open.buffer.marks().iter().map(|(&name, id)| (name, id.get())).collect(), }; self.memory.places.insert(open.buffer.doc().uid().to_string(), place); } /// Writes what cumulus remembers between runs: where you left off in /// each open document, the documents opened lately, the command history. pub fn remember(&mut self) -> io::Result<()> { self.keep_cameras(); for id in self.shared.docs.ids() { self.note_place(id); } self.memory.history = self.command_history.lines().to_vec(); // A new document never written, or one deleted since: nothing to // open there next time. self.memory.recent.retain(|recent| recent.path.exists()); self.memory.save(&self.paths.memory()) } // ----- Time passing --------------------------------------------------- /// What happens as time passes: autosave, and every second a look at /// the files for changes made outside cumulus. Returns whether there's /// something new to draw. pub fn tick(&mut self, now: Instant) -> bool { let mut changed = false; if now.saturating_duration_since(self.watched) >= WATCH_EVERY { self.watched = now; changed |= self.watch(); } changed |= self.autosave(now); changed | self.ask_about_conflicts() } /// Saves the documents whose latest change is a second old. One whose /// file changed outside cumulus since isn't saved over: that's looked /// at first. fn autosave(&mut self, now: Instant) -> bool { if !self.settings.autosave { return false; } let due: Vec = self .shared .docs .iter() .filter(|open| { open.is_dirty() && !open.is_read_only() && open.file.trouble.is_none() && open.file.changed.is_some_and(|at| now.saturating_duration_since(at) >= AUTOSAVE_AFTER) }) .map(|open| open.id) .collect(); for &id in &due { let said = if self.changed_outside(id) { self.look_at(id) } else { self.save(id).err() }; if let Some(message) = said { self.message = Some(message); } } !due.is_empty() } /// Whether a document's file changed since cumulus last read or wrote /// it. fn changed_outside(&self, id: BufferId) -> bool { let file = &self.shared.docs.get(id).file; Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp) } /// Writes a document to its file: the day's first save copies the old /// file to the backups first. fn save(&mut self, id: BufferId) -> Result<(), Message> { let backups = self.paths.backups(); let open = self.shared.docs.get_mut(id); if open.is_read_only() { return Err(read_only(open)); } let path = open.file.path.clone(); let failed = |open: &mut Open, error: String| { let text = format!("couldn't save {}: {error}", open.file_name()); open.file.trouble = Some(Trouble::SaveFailed(text.clone())); Message::problem(text) }; let text = match document_file::write(open.buffer.doc()) { Ok(text) => text, Err(error) => return Err(failed(open, error.to_string())), }; // A backup that fails mustn't stop the save itself. let _ = backup::daily(&backups, open.buffer.doc().uid(), &path, Day::today(), backup::KEEP); if let Err(error) = write_atomically(&path, &text) { return Err(failed(open, error.to_string())); } open.file.stamp = Stamp::of(&path).ok().flatten(); open.file.saved = open.buffer.history().state(); open.file.changed = None; open.file.trouble = None; Ok(()) } /// Looks at every file for changes made outside cumulus, and lets /// read-only documents be edited once the other cumulus lets go. The /// config files saved since they were read apply again. fn watch(&mut self) -> bool { let mut said = self.reread_config(); for id in self.shared.docs.ids() { said.extend(self.look_at(id)); let open = self.shared.docs.get_mut(id); if open.is_read_only() && let Access::Locked(lock) = access(&self.paths, open.buffer.doc().uid()) { open.file.access = Access::Locked(lock); said.push(Message::info(format!("{}: you can edit it now", open.file_name()))); } } let changed = !said.is_empty(); if let Some(message) = Message::joined(said) { self.message = Some(message); } changed } /// Looks at one document's file, and acts on what changed. Not while /// its text is being edited or a node moved: the document stays as it /// is under the edit, and the file is looked at again after. fn look_at(&mut self, id: BufferId) -> Option { let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_)) || self.shared.mode.any_edit().is_some(); if busy && id == self.buffer() { return None; } let open = self.shared.docs.get_mut(id); let stamp = Stamp::of(&open.file.path).ok()?; if stamp == open.file.stamp { return None; } let name = open.file_name(); let Some(stamp) = stamp else { // Gone: deleted, or moved away. Writing it back right away // could undo a move, so that waits for `:w`. open.file.stamp = None; open.file.trouble = Some(Trouble::Gone); return Some(Message::problem(format!("{name} was deleted or moved: :w writes it here again"))); }; let text = std::fs::read_to_string(&open.file.path).ok()?; open.file.stamp = Some(stamp); let newer = match document_file::parse(&text) { Ok(newer) => newer, Err(error) => { open.file.trouble = Some(Trouble::Broken(error.to_string())); return Some(Message::problem(format!( "{name} changed outside cumulus, with a mistake in it: {error} · autosave waits until it's fixed (:w saves over it)" ))); } }; if open.is_dirty() { open.file.trouble = Some(Trouble::Conflict { newer: Box::new(newer), asked: false }); return None; } Some(self.take_in(id, newer, "{} changed outside cumulus: here it is · u undoes that")) } /// Puts a document read again from its file in place. `said` is what to /// say when that's an undo step, about the file `{}`; a different /// document has its own message. fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message { let open = self.shared.docs.get_mut(id); let name = open.file_name(); let undoable = open.buffer.reload(newer); open.file.saved = open.buffer.history().state(); open.file.changed = None; open.file.trouble = None; if undoable { return Message::info(said.replace("{}", &name)); } // Another document: the lock it needs is its own. open.file.access = access(&self.paths, open.buffer.doc().uid()); Message::info(format!("{name} holds another document now: here it is")) } /// Asks which version to keep, for a document whose file changed while /// it had unsaved changes here: in normal mode, not to interrupt typing. fn ask_about_conflicts(&mut self) -> bool { if !matches!(self.shared.mode, Mode::Normal) { return false; } for open in self.shared.docs.iter_mut() { if let Some(Trouble::Conflict { asked, .. }) = &mut open.file.trouble && !*asked { *asked = true; self.shared.mode = Mode::Popup(Popup::conflict(open.id, &open.file_name())); return true; } } false } /// Keeps one version of a document whose file changed outside cumulus. pub(super) fn resolve(&mut self, id: BufferId, keep: Keep) -> Option { if !self.shared.docs.contains(id) { return None; } let open = self.shared.docs.get_mut(id); let Some(Trouble::Conflict { newer, .. }) = open.file.trouble.take() else { return None }; match keep { Keep::Mine => Some(match self.save(id) { Ok(()) => { Message::done(format!("kept yours: saved {}", self.shared.docs.get(id).file_name())) } Err(problem) => problem, }), Keep::Theirs => Some(self.take_in(id, *newer, "took the file's {} · u brings yours back")), } } // ----- Commands ------------------------------------------------------- /// `:w`, `:x` and `:wa`. pub(super) fn write(&mut self, which: SaveWhich) -> Option { let here = self.buffer(); let ids: Vec = match which { SaveWhich::This => vec![here], SaveWhich::IfChanged => { let open = self.shared.docs.get(here); if !open.is_dirty() && open.file.trouble.is_none() { return None; } vec![here] } SaveWhich::All => { self.shared.docs.iter().filter(|open| open.is_dirty()).map(|open| open.id).collect() } }; if ids.is_empty() { return Some(Message::info("nothing to save: every document is saved")); } let mut names = Vec::new(); for id in ids { // `:w` settles a conflict: what's on screen is what you keep. self.shared.docs.get_mut(id).file.trouble = None; if let Err(problem) = self.save(id) { return Some(problem); } names.push(self.shared.docs.get(id).file_name()); } Some(Message::done(format!("saved {}", names.join(", ")))) } /// `:e`: opens a document in the focused pane, or (with no path) reads /// the focused one again from its file. pub(super) fn edit(&mut self, path: Option, force: bool) -> Option { let Some(path) = path else { return self.read_again(force) }; let path = self.resolve_path(&path); let loaded = match load(&path) { Ok(loaded) => loaded, Err(error) => return Some(Message::problem(error.short())), }; let (id, said) = self.add_document(loaded); self.show(id); said } /// `:e` and `:e!` with no file: the focused document as its file has /// it. `:e` refuses to drop unsaved changes. fn read_again(&mut self, force: bool) -> Option { let id = self.buffer(); let open = self.shared.docs.get(id); let name = open.file_name(); if open.is_dirty() && !force { return Some(Message::problem(format!("{name} has unsaved changes: :e! drops them"))); } let loaded = match load(&open.file.path) { Ok(loaded) if loaded.stamp.is_some() => loaded, Ok(_) => return Some(Message::problem(format!("{name} isn't on disk yet"))), Err(error) => return Some(Message::problem(error.short())), }; let open = self.shared.docs.get_mut(id); open.file.stamp = loaded.stamp; Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that")) } /// A path typed in `:e` or `:new`: `~` is the home folder, relative paths /// start at the focused document's folder, a folder means its default /// document, and `.cum` is added when there's no extension. fn resolve_path(&self, typed: &str) -> PathBuf { let path = self.paths.expand(typed.trim()); let path = if path.is_relative() { self.folder().join(path) } else { path }; if path.is_dir() { return path.join(format!("{}.{}", self.settings.default_document, names::EXTENSION)); } if path.extension().is_none() { path.with_extension(names::EXTENSION) } else { path } } /// The focused document's folder. pub(super) fn folder(&self) -> PathBuf { let path = &self.shared.docs.get(self.buffer()).file.path; path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf) } /// `:new`: a new document next to the focused one, written right away, /// and shown. With no title, it's the folder's default document. pub(super) fn new_document(&mut self, title: Option) -> Option<Message> { let name = match &title { Some(title) => file_name(title.as_str()), None => format!("{}.{}", self.settings.default_document, names::EXTENSION), }; let path = self.folder().join(&name); if path.exists() { return match self.edit(Some(path.display().to_string()), false) { Some(problem) if problem.tone == Tone::Problem => Some(problem), _ => Some(Message::info(format!("{name} is here already: here it is"))), }; } let title = title.unwrap_or_else(|| title_from_file(&path)); match create(&path, title) { Ok(loaded) => { let (id, _) = self.add_document(loaded); self.show(id); Some(Message::done(format!("made {name}"))) } Err(error) => Some(Message::problem(error.short())), } } /// `:bd`: closes the focused document; its panes show another one. pub(super) fn close_document(&mut self, force: bool) -> Option<Message> { let id = self.buffer(); if self.shared.docs.count() == 1 { return Some(Message::info("it's the only open document: :q quits")); } if let Some(problem) = self.before_closing(&[id], force, ":bd!") { return Some(problem); } self.keep_cameras(); self.note_place(id); let index = self.shared.docs.index_of(id); let name = self.shared.docs.get(id).file_name(); self.shared.docs.remove(id); let next = self.shared.docs.at(index.saturating_sub(1)); let camera = self.shared.docs.get(next).camera; for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) { pane.show(next, pane.view.kind(), camera); } Some(Message::done(format!("closed {name}"))) } /// `:qa`, and `:q` on the last pane. pub(super) fn quit(&mut self, force: bool) -> Option<Message> { let ids = self.shared.docs.ids(); if let Some(problem) = self.before_closing(&ids, force, ":q!") { return Some(problem); } self.quit = true; None } /// What has to happen before documents close: with autosave, their /// changes are saved; without, unsaved changes stop it (unless `force`, /// which `bang` names). fn before_closing(&mut self, ids: &[BufferId], force: bool, bang: &str) -> Option<Message> { if force { return None; } for &id in ids { // Not over a change made outside cumulus: that's looked at first. if self.changed_outside(id) { self.look_at(id); } let open = self.shared.docs.get(id); let name = open.file_name(); match &open.file.trouble { Some(Trouble::Conflict { .. }) => { return Some(Message::problem(format!( "{name} changed outside cumulus: :w keeps yours, :e! takes the file's, {bang} neither" ))); } Some(Trouble::Broken(_)) if open.is_dirty() => { return Some(Message::problem(format!( "{name} has a mistake in it on disk: :w saves yours over it, {bang} doesn't" ))); } Some(Trouble::Gone) if open.is_dirty() => { return Some(Message::problem(format!( "{name} was deleted or moved: :w writes it here again, {bang} doesn't" ))); } _ => {} } if !open.is_dirty() { continue; } if !self.settings.autosave { return Some(Message::problem(format!( "{name} has unsaved changes: :w saves them, {bang} drops them" ))); } if let Err(problem) = self.save(id) { return Some(problem); } } None } /// The focused pane shows document `id`, in the same kind of view: a /// jump, so `Ctrl-o` comes back. pub(super) fn show(&mut self, id: BufferId) { if self.buffer() != id { let from = self.place_here(); self.jumping(from); self.switch_to(id); } } /// The focused pane shows document `id`, in the same kind of view. Its /// canvas camera stays with the document it showed, and a selection /// ends. pub(super) fn switch_to(&mut self, id: BufferId) { let pane = self.panes.current_mut(); if pane.buffer == id { return; } if let Some(camera) = pane.view.camera() { self.shared.docs.get_mut(pane.buffer).camera = Some(camera); } let camera = self.shared.docs.get(id).camera; pane.show(id, pane.view.kind(), camera); if let Mode::Visual(_) = self.shared.mode { self.shared.mode = Mode::Normal; } } /// The message for a change refused because the document is read-only. pub(super) fn refuse_read_only(&self) -> Option<Message> { let open = self.shared.docs.get(self.buffer()); open.is_read_only().then(|| read_only(open)) } } fn read_only(open: &Open) -> Message { Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name())) } #[cfg(test)] mod tests { use super::*; #[test] fn paths_in_messages_start_from_home() { let home = Path::new("/home/sam"); assert_eq!(shown(&home.join("ideas.cum"), home), "~/ideas.cum"); assert_eq!(shown(&home.join("notes").join("a.cum"), home), "~/notes/a.cum"); let elsewhere = Path::new("/srv/b.cum"); assert_eq!(shown(elsewhere, home), elsewhere.display().to_string()); } }