Files
ElyOffice/crates/ely-office/src/save.rs
T

466 lines
17 KiB
Rust

//! Save, save as, and conflict outcomes (FILE-04, FILE-05).
use std::{
io,
path::{Path, PathBuf},
};
use ely_files::{Open, SaveError, Saved};
use ely_gpui_component::primitives::Severity;
use gpui::{Context, TaskExt as _, Window};
use crate::files::{self, file_name};
use crate::{After, Closing, Dialog, Engine, Host, Opening, Tab, Tabs, in_window, text};
/// The file a path names, links resolved.
fn identity(path: &Path) -> io::Result<PathBuf> {
match std::fs::canonicalize(path) {
Ok(real) => Ok(real),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let parent = path.parent().unwrap_or(Path::new("."));
let name = path
.file_name()
.ok_or_else(|| io::Error::other("no file name"))?;
Ok(std::fs::canonicalize(parent)?.join(name))
}
Err(error) => Err(error),
}
}
pub trait Saving: Host {
/// The tab whose source file is `target`.
fn holder(&self, target: &Path) -> io::Result<Option<u64>> {
for tab in &self.core().tabs {
if let Some(open) = tab.session.path()
&& ely_files::same_file(open, target)?
{
return Ok(Some(tab.id));
}
}
Ok(None)
}
/// Saves in place in its own format.
fn save(&mut self, id: u64, then: Option<After>, window: &mut Window, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |host, window, cx| host.save(id, then, window, cx));
}
if self.exporting(id) {
return self.after_export(id, move |host, window, cx| host.save(id, then, window, cx));
}
let Some(tab) = self.tab(id) else {
return log::info!("office: tab {id} closed before its save");
};
let Some(path) = tab
.session
.path()
.filter(|_| tab.risk.is_none())
.map(PathBuf::from)
else {
return self.save_as(id, then, cx);
};
let format = match files::kept_format(&Self::KIND, &path) {
Ok(format) => format,
Err(error) => {
return self.finish(id, Err(SaveError::Io(error)), None, then, window, cx);
}
};
self.exported(id, format, then, true, cx, move |host, bytes, _, _| {
ely_files::save(&mut host.tab_mut(id).expect("open").session, &bytes)
});
}
/// Whether tab `id` has an export running.
fn exporting(&self, id: u64) -> bool {
self.tab(id).is_some_and(|tab| tab.exporting)
}
/// Runs `then` once tab `id`'s running export ends.
fn after_export(
&mut self,
id: u64,
then: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) + 'static,
) {
log::info!("office: a save of tab {id} waits for the running one");
self.core_mut().queued_saves.push((id, Box::new(then)));
}
/// Exports `format` off the UI thread; `write` stores the bytes.
fn exported(
&mut self,
id: u64,
format: &'static str,
then: Option<After>,
overwrite: bool,
cx: &mut Context<Self>,
write: impl FnOnce(
&mut Self,
Vec<u8>,
&mut Window,
&mut Context<Self>,
) -> Result<Saved, SaveError>
+ 'static,
) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.exporting = true;
let (work, view) = (tab.work.clone(), tab.view.clone());
let edited = Self::Engine::edited(view.read(cx));
cx.notify();
self.read_in(
id,
move |worker, doc| {
if overwrite && let Some(risk) = Self::Engine::unsound(worker, doc)? {
return Ok(Err(risk));
}
let bytes = files::kernel_bytes::<Self::Engine>(worker, doc, &work, format)?;
Ok(Ok((bytes, Self::Engine::history(worker, doc)?)))
},
move |host, read, window, cx| {
let (queued, rest): (Vec<_>, Vec<_>) = host
.core_mut()
.queued_saves
.drain(..)
.partition(|(tab, _)| *tab == id);
host.core_mut().queued_saves = rest;
let Some(tab) = host.tab_mut(id) else {
return log::info!("office: tab {id} closed during its save");
};
tab.exporting = false;
let (saved, history) = match read {
Ok(Ok((bytes, history))) => (write(host, bytes, window, cx), Some(history)),
Ok(Err(risk)) => {
// Found at the last moment: write elsewhere instead.
log::warn!("office: tab {id} is {risk:?}; its save becomes Save As");
tab.risk = Some(risk);
cx.notify();
host.save_as(id, then, cx);
for (_, save) in queued {
save(host, window, cx);
}
return;
}
Err(error) => (Err(SaveError::Io(error)), None),
};
// Edits after the export stay unsaved; a close asks again.
let changed = Self::Engine::edited(view.read(cx)) != edited
|| Self::Engine::edit_pending(view.read(cx));
match then {
Some(after) if changed && saved.is_ok() => {
host.finish(id, saved, history, None, window, cx);
host.request_close(id, after, window, cx);
}
then => host.finish(id, saved, history, then, window, cx),
}
for (_, save) in queued {
save(host, window, cx);
}
},
cx,
);
}
/// Asks where, then writes the default format there.
fn save_as(&mut self, id: u64, then: Option<After>, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let Some(dir) = folder(tab) else {
return self.panel_failed("no documents folder", cx);
};
let stem = tab
.name
.rsplit_once('.')
.map_or(tab.name.as_ref(), |(stem, _)| stem);
log::info!("office: asks where to save tab {id}");
let chosen =
cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", Self::KIND.save_as[0])));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |host, window, cx| {
match chosen {
Ok(Some(path)) => return host.write_as(id, path, then, window, cx),
Ok(None) => log::info!("office: the save panel was cancelled"),
Err(error) => host.panel_failed(&error, cx),
}
// No save: a close that waited on it stops.
if then.is_some() {
host.close_stopped(cx);
}
})
})
.detach_and_log_err(cx);
}
/// Writes `id` to `path`; conversions need new files.
fn write_as(
&mut self,
id: u64,
path: PathBuf,
then: Option<After>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.settled(id, cx) {
return self.when_settled(id, move |host, window, cx| {
host.write_as(id, path, then, window, cx)
});
}
if self.exporting(id) {
return self.after_export(id, move |host, window, cx| {
host.write_as(id, path, then, window, cx)
});
}
let target = match identity(&path) {
Ok(target) => target,
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
};
let Some(format) = files::save_as_format(&Self::KIND, &target) else {
let formats = Self::KIND.save_as.join(", .");
let text = text(cx, "office.error.format", &[("formats", &formats)]);
return self.toast(text, Severity::Danger, cx);
};
let Some(tab) = self.tab(id) else {
return log::info!("office: tab {id} closed before its save");
};
let holder = match self.holder(&target) {
Ok(holder) => holder,
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
};
if holder.is_some_and(|other| other != id) {
let text = text(cx, "office.error.taken", &[("name", &file_name(&target))]);
return self.toast(text, Severity::Danger, cx);
}
let own_format = tab
.session
.path()
.and_then(|open| files::kept_format(&Self::KIND, open).ok());
if holder == Some(id) && (tab.risk.is_some() || own_format != Some(format)) {
let text = text(cx, "office.error.convert", &[]);
return self.toast(text, Severity::Danger, cx);
}
// The written file differs from the open one; reopen it.
let reopen =
matches!(tab.risk, Some(crate::Risk::Signed | crate::Risk::Repaired)) && then.is_none();
self.exported(id, format, then, false, cx, move |host, bytes, _, cx| {
// Another tab may have taken the target meanwhile.
match host.holder(&target) {
Ok(Some(other)) if other != id => {
let message = format!("{} is open in another tab", target.display());
return Err(SaveError::Io(io::Error::other(message)));
}
Err(error) => return Err(SaveError::Io(error)),
_ => {}
}
let session = &mut host.tab_mut(id).expect("open").session;
let saved = ely_files::save_as(session, &target, &bytes);
if let Ok(done) = &saved {
host.moved(id, done, cx);
host.tab_mut(id).expect("open").reopen = reopen;
}
saved
});
}
/// The tab now names the saved file: registry, title, risk.
fn moved(&mut self, id: u64, done: &Saved, cx: &mut Context<Self>) {
let tab = self.tab(id).expect("the tab is open");
if let Some(old) = tab.registry {
self.core_mut().sessions.close(old);
}
let registry = match self.core_mut().sessions.open(&done.path, false) {
Ok(Open::Fresh(registry) | Open::View(registry) | Open::Focus(registry)) => {
Some(registry)
}
Err(error) => {
self.report(id, Err(SaveError::Io(error)), cx);
None
}
};
// Ely's writes carry no signatures; macros may stay.
let risk = match files::macro_risk(&Self::KIND, &done.path) {
Ok(macros) => macros.then_some(crate::Risk::Macros),
Err(error) => {
self.report(id, Err(SaveError::Io(error)), cx);
Some(crate::Risk::Macros)
}
};
let tab = self.tab_mut(id).expect("the tab is open");
tab.registry = registry;
tab.name = file_name(&done.path);
tab.risk = risk;
}
/// Keep Mine over the disk now; later changes conflict.
fn keep_mine(
&mut self,
id: u64,
then: Option<After>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.settled(id, cx) {
return self.when_settled(id, move |host, window, cx| {
host.keep_mine(id, then, window, cx)
});
}
if self.exporting(id) {
return self.after_export(id, move |host, window, cx| {
host.keep_mine(id, then, window, cx)
});
}
let Some(tab) = self.tab(id) else {
return log::info!("office: tab {id} closed before its save");
};
let format = tab
.session
.path()
.ok_or_else(|| io::Error::other("no path"))
.and_then(|path| files::kept_format(&Self::KIND, path));
let format = match format {
Ok(format) => format,
Err(error) => {
return self.finish(id, Err(SaveError::Io(error)), None, then, window, cx);
}
};
let seen = tab.session.path().map(ely_files::Fingerprint::of);
match seen {
Some(Ok(seen)) => {
self.tab_mut(id).expect("open").session.rebase(seen);
self.exported(id, format, then, true, cx, move |host, bytes, _, _| {
ely_files::save(&mut host.tab_mut(id).expect("open").session, &bytes)
});
}
// Gone from disk: nothing there to lose.
_ => self.exported(id, format, then, true, cx, move |host, bytes, _, _| {
ely_files::save_overwrite(&mut host.tab_mut(id).expect("open").session, &bytes)
}),
}
}
/// Reopens the tab from its file; the disk version wins.
fn reload(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(path) = self
.tab(id)
.and_then(|tab| tab.session.path().map(PathBuf::from))
else {
return;
};
self.close_tab(id, cx);
self.open_file(path, cx);
}
/// Reopens a flagged tab once nothing in memory is unsaved.
fn reopen_if_saved(&mut self, id: u64, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
in_window(&this, cx, |host, _, cx| {
let Some(tab) = host.tab(id).filter(|tab| tab.reopen) else {
return;
};
match !tab.dirty(cx) && !tab.exporting {
true => {
log::info!("office: tab {id} reopens the file it wrote");
host.reload(id, cx);
}
false => log::info!(
"office: tab {id} has edits since; it reopens after its next save"
),
}
})
})
.detach_and_log_err(cx);
}
/// `history` is the kernel's undo history read with the export.
fn finish(
&mut self,
id: u64,
saved: Result<Saved, SaveError>,
history: Option<String>,
then: Option<After>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match saved {
Ok(done) => {
log::info!("office: saved {}", done.path.display());
// A save may replace the file; the registry follows it.
let registry = self.tab(id).and_then(|tab| tab.registry);
if let Some(registry) = registry
&& let Err(error) = self.core_mut().sessions.track(registry, &done.path)
{
log::error!("office: the saved file did not register: {error}");
self.engine_failed(&error.to_string(), cx);
}
if let (Some(history), Some(tab)) = (history, self.tab_mut(id)) {
Self::Engine::saved(&tab.view, &history, cx);
tab.mark = history;
}
self.reopen_if_saved(id, cx);
cx.notify();
if let Some(after) = then {
self.close_tab(id, cx);
self.proceed(after, window, cx);
}
}
Err(SaveError::Conflict) => {
// Saves no close waits on may go to the app.
if then.is_none() && self.conflicted(id, cx) {
return;
}
self.core_mut().dialog = Some(Dialog::Conflict { tab: id, then });
cx.notify();
}
failed => {
self.report(id, failed, cx);
if then.is_some() {
self.close_stopped(cx);
}
}
}
}
/// A failed save as a toast; edits stay.
fn report(&mut self, id: u64, saved: Result<Saved, SaveError>, cx: &mut Context<Self>) {
let name = self.tab(id).map(|tab| tab.name.clone()).unwrap_or_default();
let error = match saved {
Ok(_) => return,
Err(SaveError::Refused(state)) => state.to_string(),
Err(SaveError::Io(error)) => error.to_string(),
Err(SaveError::Conflict) => {
let text = text(cx, "office.error.conflict", &[("name", &name)]);
return self.toast(text, Severity::Danger, cx);
}
};
if let Some(tab) = self.tab_mut(id) {
tab.session.save_failed();
}
let text = text(
cx,
"office.error.save",
&[("name", &name), ("error", &error)],
);
self.toast(text, Severity::Danger, cx);
cx.notify();
}
}
impl<H: Host> Saving for H {}
/// Where a panel for `tab` opens: beside it, else Documents.
pub fn folder<E, G: Engine>(tab: &Tab<E, G>) -> Option<PathBuf> {
tab.session
.path()
.and_then(|path| path.parent().map(PathBuf::from))
.or_else(|| {
directories::UserDirs::new()?
.document_dir()
.map(PathBuf::from)
})
}