//! 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 { 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> { 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, window: &mut Window, cx: &mut Context) { 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) + '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, overwrite: bool, cx: &mut Context, write: impl FnOnce( &mut Self, Vec, &mut Window, &mut Context, ) -> Result + '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::(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, cx: &mut Context) { 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, _window: &mut Window, cx: &mut Context, ) { 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) { 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, window: &mut Window, cx: &mut Context, ) { 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) { 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) { 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, history: Option, then: Option, window: &mut Window, cx: &mut Context, ) { 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, cx: &mut Context) { 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 Saving for H {} /// Where a panel for `tab` opens: beside it, else Documents. pub fn folder(tab: &Tab) -> Option { tab.session .path() .and_then(|path| path.parent().map(PathBuf::from)) .or_else(|| { directories::UserDirs::new()? .document_dir() .map(PathBuf::from) }) }