Files
cumulus/src/app/files.rs
T
skkeyeandClaude Opus 5.5 7bec5bf3fc Add visual mode, node registers, search and the jump list (stage 8c)
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]>
2026-10-02 04:12:27 -04:00

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());
}
}