//! The shell document apps share (§6), over any engine. pub mod chart; mod autosave; mod chrome; mod close; mod dialogs; pub mod engine; pub mod files; mod find; pub mod lok; pub mod menu; mod open; mod recover; pub mod panel; mod save; mod stopped; mod zoom; use std::{ io, path::PathBuf, sync::{Arc, Mutex}, }; use ely_files::{SaveState, Session, Sessions}; use ely_gpui_component::primitives::Severity; use ely_platform::AppId; use ely_ui::Root; use gpui::{ App, AppContext as _, Context, Entity, FocusHandle, SharedString, TaskExt as _, WeakEntity, Window, }; pub use autosave::Autosaving; pub use chrome::Chrome; pub use close::{After, Closing}; pub use dialogs::{Dialog, Dialogs, cancel}; pub use engine::{Engine, Load}; pub use find::{FindReplace, Finder, Finding}; pub use lok::{Commands, Lok, start}; pub use open::{Opening, Source}; pub use recover::{Keeping, Recovering}; pub use stopped::{Stopped, Stopping}; pub use save::{Saving, folder}; pub use zoom::{ActualSize, ZoomIn, ZoomOut, Zooming, bind_zoom_keys, zoom_items}; /// Every shared trait, for `use ely_office::prelude::*`. pub mod prelude { pub use crate::{Chrome, Closing, Commands, Dialogs, Finding, Host, Opening, Saving, Stopping, Tabs, Zooming}; } /// What one app opens and writes. pub struct Kind { pub app: AppId, /// Formats a save keeps, as the engine names them. pub kept: &'static [&'static str], /// Formats Save As converts to; the first is the default. pub save_as: &'static [&'static str], /// Extensions that always carry macros; None for macro-free formats. pub macros: Option<&'static [&'static str]>, } /// An app built on the shared shell. pub trait Host: Sized + 'static { /// What the app keeps per tab. type Extra: Default; type Engine: Engine; const KIND: Kind; fn core(&self) -> &Core; fn core_mut(&mut self) -> &mut Core; /// Runs when the kernel reports on tab `id`. fn observed(&mut self, id: u64, view: &Entity>, cx: &mut Context); /// Runs before a search first moves the cursor. fn search_leaves(&mut self, _: &mut Context) {} /// Runs as a quit starts, before any tab closes. fn quitting(&mut self, _: &mut Context) {} /// Runs when a close stops: question or save cancelled. fn close_cancelled(&mut self, _: &mut Context) {} /// An outside change met a save; true if shown. fn conflicted(&mut self, _id: u64, _: &mut Context) -> bool { false } /// Runs as tab `tab` leaves, saved, clean or discarded. fn tab_closed(&mut self, _tab: &Tab, _: &mut Context) {} /// Tab `tab`'s document as last seen; reads and numbered commands. fn read_at(&self, _tab: u64, _cx: &App) -> Option<::Doc> { None } } /// The view type of host `H`'s engine. pub type View = <::Engine as Engine>::View; /// The worker type of host `H`'s engine. pub type WorkerOf = <::Engine as Engine>::Worker; /// The document type of host `H`'s engine. pub type DocOf = <::Engine as Engine>::Doc; /// Why saving in place would lose something. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Risk { /// Macros Ely will not round-trip (LOK-11). Macros, /// Signatures an edit breaks (§11.4). Signed, /// A damaged file opened through a repaired copy (PDF-14). Repaired, } /// One open document. pub struct Tab { pub id: u64, pub view: Entity, pub doc: G::Doc, pub session: Session, /// Its id in the same-file registry; untitled tabs have none. pub registry: Option, pub name: SharedString, /// The kernel says it is modified. pub modified: bool, /// Why Save writes a copy, if it does. pub risk: Option, /// The kernel's undo history at the last load or save. pub mark: String, pub work: PathBuf, /// A save's export runs in the background. pub exporting: bool, /// Reopen from the file once memory matches it. pub reopen: bool, pub extra: E, } impl Tab { pub fn dirty(&self, cx: &App) -> bool { self.modified || self.session.state() != SaveState::Saved || G::edit_pending(self.view.read(cx)) } /// The status line for this tab (FILE-02). pub fn state_key(&self, cx: &App) -> &'static str { match self.session.state() { _ if self.risk == Some(Risk::Macros) => "office.state.risk", _ if self.risk == Some(Risk::Signed) => "office.state.signed", _ if self.risk == Some(Risk::Repaired) => "office.state.repaired", _ if self.exporting => "office.state.saving", SaveState::Saving => "office.state.saving", SaveState::ExternalModified => "office.state.external", SaveState::SaveFailed => "office.state.failed", _ if self.dirty(cx) => "office.state.dirty", _ => "office.state.saved", } } } /// A continuation run once a tab's input lands. pub type Waiting = Box)>; /// The shared state of one window. pub struct Core { pub worker: Arc>>, pub sessions: Sessions, pub tabs: Vec>, pub active: Option, /// Loads in flight: tab id and name. pub loading: Vec<(u64, SharedString)>, pub focus: FocusHandle, pub root: WeakEntity, pub work: PathBuf, pub next: u64, pub untitled: u32, pub dialog: Option, /// Locked files waiting: path, a password failed, a copy. pub locked: std::collections::VecDeque<(PathBuf, bool, bool)>, /// The window that async results update. pub window: Option, /// Work that waits for a tab's queued input. pub waiting: Vec<(u64, Waiting)>, /// Saves that wait for a tab's running export. pub queued_saves: Vec<(u64, Waiting)>, /// The open find box. pub finder: Option, /// Recovery points and crash return; none when off or failed. pub keeping: Option, /// Why recovery could not start, shown once. pub recovery_off: Option, /// The engine ended under the open tabs (§6.3). pub stopped: Option, /// Edit counts at the last autosave look, per tab. pub quiet: std::collections::HashMap, } impl Core { pub fn new(worker: Arc>>, work: PathBuf, cx: &mut Context) -> Self { Core { worker, sessions: Sessions::default(), tabs: Vec::new(), active: None, loading: Vec::new(), focus: cx.focus_handle(), root: WeakEntity::new_invalid(), work, next: 0, untitled: 0, dialog: None, locked: Default::default(), window: None, waiting: Vec::new(), queued_saves: Vec::new(), finder: None, keeping: None, recovery_off: None, stopped: None, quiet: Default::default(), } } } /// An emptied scratch root under an app's data folder. pub fn fresh_work(data: &std::path::Path) -> io::Result { let work = data.join("work"); if work.try_exists()? { std::fs::remove_dir_all(&work)?; } std::fs::create_dir_all(&work)?; Ok(work) } /// Runs `f` on the host inside its window. pub fn in_window( this: &WeakEntity, cx: &mut gpui::AsyncApp, f: impl FnOnce(&mut H, &mut Window, &mut Context) -> R, ) -> anyhow::Result { let handle = this .read_with(cx, |host, _| host.core().window)? .ok_or_else(|| anyhow::anyhow!("the documents have no window"))?; cx.update_window(handle, |_, window, cx| { this.update(cx, |host, cx| f(host, window, cx)) })? } /// Opens the print panel after GPUI releases the app. pub fn print_later(pdf: std::path::PathBuf, cx: &mut Context) { // The panel's modal loop calls back into GPUI. cx.spawn(async move |this, cx| { let printed = ely_platform::print::print_pdf(&pdf).await; // The copy may hold what a password guarded. if let Err(error) = std::fs::remove_file(&pdf) { log::error!("office: the print copy {} stays: {error}", pdf.display()); } match printed { Ok(printed) => log::info!("office: print done, printed {printed}"), Err(error) => { if this .update(cx, |host, cx| host.print_failed(&error.to_string(), cx)) .is_err() { log::error!("office: printing failed after its window closed: {error}"); } } } }) .detach(); } /// Addresses a click may open outside: web and mail. pub fn outward(uri: &str) -> bool { // Schemes ignore case. ["https://", "http://", "mailto:"] .iter() .any(|scheme| uri.get(..scheme.len()).is_some_and(|head| head.eq_ignore_ascii_case(scheme))) } /// A localized message. pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { ely_ui::i18n::text(cx, key, args) } /// Tabs, toasts and queued input. pub trait Tabs: Host { fn tab(&self, id: u64) -> Option<&Tab> { self.core().tabs.iter().find(|tab| tab.id == id) } fn tab_mut(&mut self, id: u64) -> Option<&mut Tab> { self.core_mut().tabs.iter_mut().find(|tab| tab.id == id) } fn active_tab(&self) -> Option<&Tab> { self.core().active.and_then(|id| self.tab(id)) } fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) { log::warn!("{}: {text}", Self::KIND.app.slug()); self.toast_unlogged(text, severity, cx); } fn print_failed(&self, error: &str, cx: &mut App) { let text = text(cx, "office.print.failed", &[("error", error)]); self.toast(text, Severity::Danger, cx); } /// A success toast with a button that opens `path`. fn toast_output(&self, text: SharedString, path: std::path::PathBuf, cx: &mut App) { log::info!("{}: {text}", Self::KIND.app.slug()); if let Some(root) = self.core().root.upgrade() { root.update(cx, |root, cx| root.toast_output(text, Severity::Success, path, cx)); } } /// A toast quoting document text; the caller logs a summary. fn toast_unlogged(&self, text: SharedString, severity: Severity, cx: &mut App) { if let Some(root) = self.core().root.upgrade() { root.update(cx, |root, cx| root.toast(text, severity, cx)); } } /// A kernel failure as a toast. fn engine_failed(&self, error: &str, cx: &mut App) { let text = text(cx, "office.error.engine", &[("error", error)]); self.toast(text, Severity::Danger, cx); } /// Whether every input sent to `id` reached the kernel. fn settled(&self, id: u64, cx: &App) -> bool { self.tab(id) .is_none_or(|tab| Self::Engine::settled(tab.view.read(cx))) } /// Runs `then` once the queued input of `id` lands. fn when_settled( &mut self, id: u64, then: impl FnOnce(&mut Self, &mut Window, &mut Context) + 'static, ) { log::info!("{}: tab {id} waits for queued input", Self::KIND.app.slug()); self.core_mut().waiting.push((id, Box::new(then))); } /// Reads tab `id` in the background; `then` gets the window. fn read_in( &self, id: u64, read: impl FnOnce(&mut WorkerOf, &DocOf) -> io::Result + Send + 'static, then: impl FnOnce(&mut Self, io::Result, &mut Window, &mut Context) + 'static, cx: &mut Context, ) { let Some(tab) = self.tab(id) else { return; }; let doc = self.read_at(id, cx).unwrap_or_else(|| tab.doc.clone()); let worker = Arc::clone(&self.core().worker); cx.spawn(async move |this, cx| { let read = cx .background_executor() .spawn(async move { read(&mut worker.lock().expect("the worker lock"), &doc) }) .await; in_window(&this, cx, |host, window, cx| then(host, read, window, cx)) }) .detach_and_log_err(cx); } /// A dialog's read; dropped once tab `id` is hidden. fn ask_in( &self, id: u64, read: impl FnOnce(&mut WorkerOf, &DocOf) -> io::Result + Send + 'static, then: impl FnOnce(&mut Self, io::Result, &mut Window, &mut Context) + 'static, cx: &mut Context, ) { self.read_in( id, read, move |host, read, window, cx| { if host.core().active != Some(id) { log::info!( "{}: tab {id} left; its dialog is dropped", Self::KIND.app.slug() ); return; } then(host, read, window, cx) }, cx, ); } } impl Tabs for H {} #[cfg(test)] mod outward_tests { #[test] fn web_and_mail_go_out_in_any_case() { for uri in ["https://a.example/", "HTTPS://a.example/", "Http://a.example/", "MAILTO:x@a.example"] { assert!(super::outward(uri), "{uri}"); } for uri in ["ftp://a.example/", "file:///etc/passwd", "javascript:alert(1)", "httpsx://a", "ht", "\u{e9}https://"] { assert!(!super::outward(uri), "{uri}"); } } }