Run cumulus for real: the app core and its files (stage 8a)
The playground becomes app::App, and `cumulus` runs it: the main loop with terminal setup and a panic hook, and the startup rules (./ideas.cum, else ~/ideas.cum, made the first time; files and folders by name; `cumulus new`; --paths). The documents' files: - autosave a second after the latest change (`:set noautosave` for vim-style :w), [+] in the document bar for unsaved changes; quitting saves first; without autosave :q refuses, :q! drops, ZZ saves first; - a file changed outside cumulus is read again as an undo step; with unsaved changes here too, cumulus asks which to keep; a file with a mistake in it holds autosave until it's fixed or :w saves over it; a deleted one waits for :w; nothing is reloaded under an edit; - the day's first save backs the file up to the data folder (30 days); - a document open in another cumulus is read-only ([RO]) until that one lets go: OS file locks in the data folder, so a crash leaves none; - where you left off (cursor, zoom, scroll), the recent documents (now in the documents picker) and the command history, in memory.ron; - :e (alone: read it again; ! drops changes), :new, :bd, :w, :wa, :x, :qa, <Space>i for :in, and :set for every setting. glyphs=ascii now reaches the frames and bars too, not just the canvas and outline. Stage 8 comes in four parts with a commit each (the user's choice); 8b is the settings files. The example is now a playground running the app on copies of the sample documents in a temporary folder, and the end-to-end tests moved to src/app/tests.rs, with tests for the files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e6f3c06ecc
commit
f6dc4564aa
47 files changed
+3985
-1024
No files matched your search
@@ -0,0 +1,657 @@
|
||||
//! 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::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 super::{App, Pane};
|
||||
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<Stamp>,
|
||||
}
|
||||
|
||||
/// 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<Loaded, OpenError> {
|
||||
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<Loaded, OpenError> {
|
||||
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<Message>) {
|
||||
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<Message>) {
|
||||
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<String> = 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<Message>) {
|
||||
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.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,
|
||||
};
|
||||
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<BufferId> = 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.
|
||||
fn watch(&mut self) -> bool {
|
||||
let mut said = Vec::new();
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
let here = self.buffer();
|
||||
let ids: Vec<BufferId> = 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<String>, force: bool) -> Option<Message> {
|
||||
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<Message> {
|
||||
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<Title>) -> 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 = Pane::new(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. Its
|
||||
/// canvas camera stays with the document it showed.
|
||||
pub(super) fn show(&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 = Pane::new(id, pane.view.kind(), camera);
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user