Files
ElyOffice/crates/ely-office/src/lib.rs
T
ZacharyZhang-NYandClaude Opus 5.5 13546ea899 R7.6: Windows and Linux print (#3)
Windows prints through the system dialog at the document's paper;
Linux through the desktop portal, the dialog's choices laid out by Ely.
Round 3 findings fixed without a further review, as the rules say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 16:59:46 -04:00

406 lines
14 KiB
Rust

//! 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<Self>;
fn core_mut(&mut self) -> &mut Core<Self>;
/// Runs when the kernel reports on tab `id`.
fn observed(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>);
/// Runs before a search first moves the cursor.
fn search_leaves(&mut self, _: &mut Context<Self>) {}
/// Runs as a quit starts, before any tab closes.
fn quitting(&mut self, _: &mut Context<Self>) {}
/// Runs when a close stops: question or save cancelled.
fn close_cancelled(&mut self, _: &mut Context<Self>) {}
/// An outside change met a save; true if shown.
fn conflicted(&mut self, _id: u64, _: &mut Context<Self>) -> bool {
false
}
/// Runs as tab `tab` leaves, saved, clean or discarded.
fn tab_closed(&mut self, _tab: &Tab<Self::Extra, Self::Engine>, _: &mut Context<Self>) {}
/// Tab `tab`'s document as last seen; reads and numbered commands.
fn read_at(&self, _tab: u64, _cx: &App) -> Option<<Self::Engine as Engine>::Doc> {
None
}
}
/// The view type of host `H`'s engine.
pub type View<H> = <<H as Host>::Engine as Engine>::View;
/// The worker type of host `H`'s engine.
pub type WorkerOf<H> = <<H as Host>::Engine as Engine>::Worker;
/// The document type of host `H`'s engine.
pub type DocOf<H> = <<H as Host>::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<E, G: Engine> {
pub id: u64,
pub view: Entity<G::View>,
pub doc: G::Doc,
pub session: Session,
/// Its id in the same-file registry; untitled tabs have none.
pub registry: Option<usize>,
pub name: SharedString,
/// The kernel says it is modified.
pub modified: bool,
/// Why Save writes a copy, if it does.
pub risk: Option<Risk>,
/// 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<E, G: Engine> Tab<E, G> {
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<H> = Box<dyn FnOnce(&mut H, &mut Window, &mut Context<H>)>;
/// The shared state of one window.
pub struct Core<H: Host> {
pub worker: Arc<Mutex<WorkerOf<H>>>,
pub sessions: Sessions,
pub tabs: Vec<Tab<H::Extra, H::Engine>>,
pub active: Option<u64>,
/// Loads in flight: tab id and name.
pub loading: Vec<(u64, SharedString)>,
pub focus: FocusHandle,
pub root: WeakEntity<Root>,
pub work: PathBuf,
pub next: u64,
pub untitled: u32,
pub dialog: Option<Dialog>,
/// 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<gpui::AnyWindowHandle>,
/// Work that waits for a tab's queued input.
pub waiting: Vec<(u64, Waiting<H>)>,
/// Saves that wait for a tab's running export.
pub queued_saves: Vec<(u64, Waiting<H>)>,
/// The open find box.
pub finder: Option<Finder>,
/// Recovery points and crash return; none when off or failed.
pub keeping: Option<Keeping>,
/// Why recovery could not start, shown once.
pub recovery_off: Option<String>,
/// The engine ended under the open tabs (§6.3).
pub stopped: Option<Stopped>,
/// Edit counts at the last autosave look, per tab.
pub quiet: std::collections::HashMap<u64, u64>,
}
impl<H: Host> Core<H> {
pub fn new(worker: Arc<Mutex<WorkerOf<H>>>, work: PathBuf, cx: &mut Context<H>) -> 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<PathBuf> {
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<H: Host, R>(
this: &WeakEntity<H>,
cx: &mut gpui::AsyncApp,
f: impl FnOnce(&mut H, &mut Window, &mut Context<H>) -> R,
) -> anyhow::Result<R> {
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<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
// 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::Extra, Self::Engine>> {
self.core().tabs.iter().find(|tab| tab.id == id)
}
fn tab_mut(&mut self, id: u64) -> Option<&mut Tab<Self::Extra, Self::Engine>> {
self.core_mut().tabs.iter_mut().find(|tab| tab.id == id)
}
fn active_tab(&self) -> Option<&Tab<Self::Extra, Self::Engine>> {
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<Self>) + '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<T: Send + 'static>(
&self,
id: u64,
read: impl FnOnce(&mut WorkerOf<Self>, &DocOf<Self>) -> io::Result<T> + Send + 'static,
then: impl FnOnce(&mut Self, io::Result<T>, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
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<T: Send + 'static>(
&self,
id: u64,
read: impl FnOnce(&mut WorkerOf<Self>, &DocOf<Self>) -> io::Result<T> + Send + 'static,
then: impl FnOnce(&mut Self, io::Result<T>, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
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<H: Host> 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}");
}
}
}