The rest of vim, on nodes. Visual mode takes in what lies between where it started and the cursor: on the canvas, every node the rectangle from one end's box to the other's touches; in the outline and the Note view, the nodes in between in reading order. V takes whole branches, Ctrl+click and Ctrl+drag pick with the mouse, and the operators take exactly what's selected: d, y, > and <, M, and the pickers on every node at once. Registers now hold text or nodes, so yy, dd and x keep what they take and p, P and ]p paste it, layout and links included. "+ is the system clipboard through arboard: nodes go there as Markdown in the user's format (## headings, a --- tag list, the body), and come back whole while the clipboard still holds them. clipboard = "unnamedplus" makes plain yanks use it, as in vim. . repeats the last change made with keys, with the text typed after it. / opens a Telescope-style search: fuzzy matches over titles, tags and bodies, best first, with the note of the one you're on beside them; n and N go on with it, and :find searches straight away. Each pane keeps a jump list for Ctrl-o and Ctrl-i that crosses documents and reopens closed ones. Capital marks lead into any document and, like the small ones, are kept in memory.ron. gd follows a cross-link, and :marks, :jumps and :registers list what they hold. Co-Authored-By: Claude Opus 5.5 <[email protected]>
675 lines
27 KiB
Rust
675 lines
27 KiB
Rust
//! 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<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.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<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. 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<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.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());
|
|
}
|
|
}
|