Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
//! Ordinary files save themselves when chosen (§6.2).
use std::time::Duration;
use ely_files::SaveState;
use gpui::{AsyncApp, Context, WeakEntity};
use crate::{Engine, Host, Saving as _, in_window};
/// How long typing rests before a save.
const QUIET: Duration = Duration::from_secs(2);
pub trait Autosaving: Host {
/// From the app's window, never its tests.
fn start_autosave(cx: &mut Context<Self>) {
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
loop {
cx.background_executor().timer(QUIET).await;
if in_window(&this, cx, |host, window, cx| host.autosave(window, cx)).is_err() {
break;
}
}
})
.detach();
}
/// Saves each eligible tab whose edits rested a whole beat.
fn autosave(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
let on = ely_ui::settings::autosave::on(cx);
let core = self.core();
if !on || core.stopped.is_some() || core.dialog.is_some() {
self.core_mut().quiet.clear();
return;
}
let edits: Vec<(u64, u64)> = core
.tabs
.iter()
.filter(|tab| {
let ours = tab.session.path().and_then(|path| path.extension()).and_then(|ext| ext.to_str()).is_some_and(
|ext| Self::KIND.save_as.iter().any(|format| format.eq_ignore_ascii_case(ext)),
);
ours && tab.risk.is_none()
&& !tab.exporting
&& tab.session.state() == SaveState::Dirty
&& Self::Engine::settled(tab.view.read(cx))
})
.map(|tab| (tab.id, Self::Engine::edited(tab.view.read(cx))))
.collect();
let due: Vec<u64> = edits
.iter()
.filter(|(id, edited)| self.core().quiet.get(id) == Some(edited))
.map(|(id, _)| *id)
.collect();
self.core_mut().quiet = edits.into_iter().filter(|(id, _)| !due.contains(id)).collect();
for id in due {
log::info!("office: autosaving tab {id}");
self.save(id, None, window, cx);
}
}
}
impl<H: Host> Autosaving for H {}
+98
View File
@@ -0,0 +1,98 @@
//! The look rows chart dialogs share: kind, titles, legend.
use ely_gpui_component::{
data_display::DescriptionList,
forms::{Choice, Input, Select, Switch, TextInput},
};
use ely_lok::sheet::{CHART_KINDS, Look};
use gpui::{App, AppContext as _, Entity, ParentElement as _, Styled as _, Window, div};
fn text(cx: &App, key: &str) -> gpui::SharedString {
ely_ui::i18n::text(cx, key, &[])
}
/// A new chart's look: columns with a legend.
pub fn fresh() -> Look {
Look {
kind: "column".into(),
legend: true,
..Look::default()
}
}
/// The look's text fields.
pub struct LookFields {
pub title: Entity<TextInput>,
pub x_title: Entity<TextInput>,
pub y_title: Entity<TextInput>,
}
/// A labelled text field holding `value`.
pub fn field(value: &str, key: &str, window: &mut Window, cx: &mut App) -> Entity<TextInput> {
let (value, name) = (value.to_string(), text(cx, key));
cx.new(|cx| {
let mut input = TextInput::new(window, cx).label(name);
input.set_text(value, cx);
input
})
}
impl LookFields {
pub fn new(look: &Look, window: &mut Window, cx: &mut App) -> Self {
LookFields {
title: field(&look.title, "office.chart.heading", window, cx),
x_title: field(&look.x_title, "office.chart.x", window, cx),
y_title: field(&look.y_title, "office.chart.y", window, cx),
}
}
/// `look` with the fields' text.
pub fn read(&self, look: &Look, cx: &App) -> Look {
let text = |field: &Entity<TextInput>| field.read(cx).text().trim().to_string();
Look {
title: text(&self.title),
x_title: text(&self.x_title),
y_title: text(&self.y_title),
..look.clone()
}
}
}
/// `rows` with kind and titles added, and the legend switch.
pub fn look_rows(
id: &str,
rows: DescriptionList,
(look, fields): (&Look, &LookFields),
on_kind: impl Fn(String, &mut App) + 'static,
on_legend: impl Fn(bool, &mut App) + 'static,
cx: &App,
) -> (DescriptionList, Switch) {
let label = |key: &str| text(cx, key);
let mut kinds: Vec<&str> = CHART_KINDS.to_vec();
if !kinds.contains(&look.kind.as_str()) {
kinds.push("other");
}
let kind = Select::new(
format!("{id}-kind"),
kinds
.into_iter()
.map(|value| Choice::new(value, label(&format!("office.chart.{value}")))),
)
.label(label("office.chart.kind"))
.selected(look.kind.clone())
.on_change(move |value, _, cx| on_kind(value.to_string(), cx));
let legend = Switch::new(format!("{id}-legend"), look.legend)
.label(label("office.chart.legend"))
.on_change(move |on, _, cx| on_legend(on, cx));
let input = |field| div().w_full().max_w_48().child(Input::new(field));
let mut rows = rows
.item(label("office.chart.kind"), div().w_full().max_w_48().child(kind))
.item(label("office.chart.heading"), input(&fields.title));
// A pie has no axes.
if look.kind != "pie" {
rows = rows
.item(label("office.chart.x"), input(&fields.x_title))
.item(label("office.chart.y"), input(&fields.y_title));
}
(rows, legend)
}
+70
View File
@@ -0,0 +1,70 @@
//! The tab strip and the save state (FILE-02).
use ely_gpui_component::{
feedback::Banner,
navigation::{EditorTab, EditorTabs},
primitives::Severity,
};
use gpui::{Context, SharedString};
use crate::{After, Closing, Host, Tabs, text};
pub trait Chrome: Host {
fn tab_strip(&self, cx: &mut Context<Self>) -> EditorTabs {
let this = cx.entity();
let core = self.core();
let open = core
.tabs
.iter()
.map(|tab| EditorTab::new(tab.id.to_string(), tab.name.clone()).dirty(tab.dirty(cx)));
let loading = core
.loading
.iter()
.map(|(id, name)| EditorTab::new(id.to_string(), name.clone()).preview(true));
let tabs = EditorTabs::new("office-tabs", open.chain(loading).collect::<Vec<_>>())
.on_select({
let this = this.clone();
move |id, _, cx| {
let id = id.parse().expect("tab ids are numbers");
this.update(cx, |host, cx| {
host.core_mut().active = Some(id);
cx.notify();
});
}
})
.on_close(move |id, window, cx| {
let id = id.parse().expect("tab ids are numbers");
this.update(cx, |host, cx| {
host.request_close(id, After::Tab, window, cx)
});
});
match core.active {
Some(id) => tabs.selected(id.to_string()),
None => tabs,
}
}
/// The macro warning over a risky document (LOK-11).
fn risk_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
let tab = self.active_tab()?;
let formats = Self::KIND.save_as.join("/").to_uppercase();
let key = match tab.risk? {
crate::Risk::Macros => "office.risk",
crate::Risk::Signed => "office.signed",
crate::Risk::Repaired => "office.repaired",
};
let warning = text(cx, key, &[("name", &tab.name), ("formats", &formats)]);
Some(Banner::new("office-risk", Severity::Warning, warning))
}
/// The active tab's save state, or the load.
fn save_state(&self, cx: &mut Context<Self>) -> SharedString {
match (self.active_tab(), self.core().loading.first()) {
(Some(tab), _) => text(cx, tab.state_key(cx), &[]),
(None, Some((_, name))) => text(cx, "office.loading", &[("name", name)]),
(None, None) => SharedString::default(),
}
}
}
impl<H: Host> Chrome for H {}
+225
View File
@@ -0,0 +1,225 @@
//! File actions, the system file panel, close and quit (FILE-03).
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs};
use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window};
use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text};
/// What a finished close or save goes on to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum After {
Tab,
Window,
Quit,
}
pub trait Closing: Host {
fn on_open(&mut self, _: &Open, _: &mut Window, cx: &mut Context<Self>) {
self.pick(false, cx, |host, path, cx| host.open_file(path, cx));
}
fn on_save(&mut self, _: &Save, window: &mut Window, cx: &mut Context<Self>) {
match self.core().active {
Some(id) => self.save(id, None, window, cx),
None => self.no_document(cx),
}
}
fn on_save_as(&mut self, _: &SaveAs, _: &mut Window, cx: &mut Context<Self>) {
match self.core().active {
Some(id) => self.save_as(id, None, cx),
None => self.no_document(cx),
}
}
fn on_close_tab(&mut self, _: &CloseTab, window: &mut Window, cx: &mut Context<Self>) {
if let Some(id) = self.core().active {
self.request_close(id, After::Tab, window, cx);
}
}
fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context<Self>) {
self.proceed(After::Quit, window, cx);
}
fn no_document(&self, cx: &mut Context<Self>) {
let text = text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
}
/// A failed system panel is a failure, never a cancel.
fn panel_failed(&self, error: &str, cx: &mut App) {
let text = text(cx, "office.error.panel", &[("error", error)]);
self.toast(text, Severity::Danger, cx);
}
/// Asks for one file, or one folder when `folder`.
fn pick(
&mut self,
folder: bool,
cx: &mut Context<Self>,
then: impl FnOnce(&mut Self, PathBuf, &mut Context<Self>) + 'static,
) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: !folder,
directories: folder,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let picked = match picked.await {
Ok(Ok(paths)) => Ok(paths.and_then(|paths| paths.into_iter().next())),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |host, _, cx| match picked {
Ok(Some(path)) => then(host, path, cx),
Ok(None) => log::info!("office: the file panel was cancelled"),
Err(error) => host.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Closes `id`, asking first when it holds unsaved changes.
fn request_close(
&mut self,
id: u64,
after: After,
_window: &mut Window,
cx: &mut Context<Self>,
) {
log::info!("office: closing tab {id} asked, then {after:?}");
if !self.settled(id, cx) {
return self.when_settled(id, move |host, window, cx| {
host.request_close(id, after, window, cx)
});
}
let Some(tab) = self.tab(id) else {
return;
};
if tab.dirty(cx) {
return self.ask_unsaved(id, after, cx);
}
// The undo history decides; it is read in the background.
let edited = Self::Engine::edited(tab.view.read(cx));
self.read_in(
id,
Self::Engine::history,
move |host, read, window, cx| {
let Some(tab) = host.tab(id) else {
return;
};
if Self::Engine::edited(tab.view.read(cx)) != edited {
return host.request_close(id, after, window, cx);
}
let moved = match read {
Ok(history) => history != tab.mark,
Err(error) => {
log::error!("office: the undo history of tab {id} is unreadable: {error}");
true
}
};
if moved || tab.dirty(cx) {
return host.ask_unsaved(id, after, cx);
}
host.close_tab(id, cx);
host.proceed(after, window, cx);
},
cx,
);
}
fn ask_unsaved(&mut self, id: u64, after: After, cx: &mut Context<Self>) {
self.core_mut().dialog = Some(Dialog::Unsaved { tab: id, after });
cx.notify();
}
/// Window and quit close each tab, asking for dirty ones.
fn proceed(&mut self, after: After, window: &mut Window, cx: &mut Context<Self>) {
if after == After::Tab {
return;
}
if let Some(id) = self.core().tabs.first().map(|tab| tab.id) {
return self.request_close(id, after, window, cx);
}
match after {
After::Window => window.remove_window(),
After::Quit => cx.quit(),
After::Tab => {}
}
}
/// The close button: tabs close in turn, off the window.
fn should_close(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if self.core().tabs.is_empty() {
return true;
}
// Each tab's history is read in the background.
self.proceed(After::Window, window, cx);
false
}
/// Releases the kernel document, registry entry and scratch folder.
fn close_tab(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(doc) = self.release_tab(id, cx) else {
return;
};
// The kernel lets go in the background.
let worker = std::sync::Arc::clone(&self.core().worker);
cx.spawn(async move |this, cx| {
let closed = cx
.background_spawn(async move {
Self::Engine::close(&mut worker.lock().expect("the worker lock"), doc)
})
.await;
if let Err(error) = closed
&& this
.update(cx, |host, cx| host.engine_failed(&error.to_string(), cx))
.is_err()
{
log::error!("office: closing a document failed after its window: {error}");
}
})
.detach();
}
/// Drops `id` from the window; its kernel document is returned.
fn release_tab(&mut self, id: u64, cx: &mut Context<Self>) -> Option<crate::DocOf<Self>> {
let core = self.core_mut();
let at = core.tabs.iter().position(|tab| tab.id == id)?;
let tab = core.tabs.remove(at);
self.tab_closed(&tab, cx);
// Its images leave GPUI's atlas next frame (PERF-08).
let mut images = Self::Engine::retire(&tab.view, cx);
cx.spawn(async move |this, cx| {
let freed = crate::in_window(&this, cx, |_, window, _| {
window.on_next_frame(move |window, _| images.release_all(window));
});
if freed.is_err() {
log::info!("office: the window went, and its images with it");
}
})
.detach();
crate::Recovering::forget_tab(self, id, cx);
let core = self.core_mut();
core.waiting.retain(|(waiting, _)| *waiting != id);
if let Some(registry) = tab.registry {
core.sessions.close(registry);
}
if core.active == Some(id) {
core.active = core.tabs.last().map(|tab| tab.id);
}
if let Err(error) = std::fs::remove_dir_all(&tab.work) {
log::warn!("office: scratch {} stays: {error}", tab.work.display());
}
log::info!("office: closed {}", tab.name);
cx.notify();
Some(tab.doc)
}
}
impl<H: Host> Closing for H {}
+234
View File
@@ -0,0 +1,234 @@
//! The shell's dialogs: unsaved, conflict, password (FILE-05).
use std::{path::PathBuf, rc::Rc};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::TextInput,
overlays::{Close, Dialog as Card, PromptDialog},
};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, SharedString, Window};
use crate::files::file_name;
use crate::{After, Closing, Host, Opening, Saving, Source, Tabs, text};
pub enum Dialog {
Unsaved {
tab: u64,
after: After,
},
Conflict {
tab: u64,
then: Option<After>,
},
Password {
path: PathBuf,
wrong: bool,
/// An untitled copy, not the user's file.
copy: bool,
field: Entity<TextInput>,
},
}
pub trait Dialogs: Host {
/// Asks for `path`'s password; `wrong` when one just failed.
fn ask_password(
&mut self,
path: PathBuf,
wrong: bool,
copy: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
log::info!(
"office: {} is locked, a password failed: {wrong}",
path.display()
);
if self.core().dialog.is_some() {
return self.core_mut().locked.push_back((path, wrong, copy));
}
let field = cx.new(|cx| TextInput::new(window, cx).masked());
self.core_mut().dialog = Some(Dialog::Password { path, wrong, copy, field });
cx.notify();
}
/// Clears the dialog; a waiting locked file asks next.
fn dialog_done(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.core_mut().dialog = None;
if let Some((path, wrong, copy)) = self.core_mut().locked.pop_front() {
self.ask_password(path, wrong, copy, window, cx);
}
cx.notify();
}
/// The shell's open dialog, if any.
fn core_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.entity();
let dismiss = {
let this = this.clone();
move |window: &mut Window, cx: &mut App| {
this.update(cx, |host, cx| host.dialog_done(window, cx))
}
};
let name = |tab| {
self.tab(tab)
.map(|tab| tab.name.clone())
.unwrap_or_default()
};
let card = match self.core().dialog.as_ref()? {
Dialog::Password { path, wrong, copy, field } => {
let prompt = password(this, (path, *wrong, *copy, field), dismiss, cx);
return Some(prompt.into_any_element());
}
Dialog::Unsaved { tab, after } => {
let (tab, after) = (*tab, *after);
let title = text(cx, "office.unsaved.title", &[("name", &name(tab))]);
let owner = this.clone();
// Cancel stops the whole close.
let cancelled = move |window: &mut Window, cx: &mut App| {
owner.update(cx, |host, cx| {
host.close_cancelled(cx);
host.dialog_done(window, cx)
})
};
Card::new("office-unsaved", title, cancelled)
.detail(text(cx, "office.unsaved.detail", &[]))
.action(cancel(cx))
.action(choice(
&this,
"office-unsaved-discard",
text(cx, "office.unsaved.discard", &[]),
ButtonVariant::Danger,
move |host, window, cx| {
host.close_tab(tab, cx);
host.proceed(after, window, cx);
},
))
.action(choice(
&this,
"office-unsaved-save",
text(cx, "office.unsaved.save", &[]),
ButtonVariant::Primary,
move |host, window, cx| host.save(tab, Some(after), window, cx),
))
}
Dialog::Conflict { tab, then } => {
let (tab, then) = (*tab, *then);
let title = text(cx, "office.conflict.title", &[("name", &name(tab))]);
let owner = this.clone();
let cancelled = move |window: &mut Window, cx: &mut App| {
owner.update(cx, |host, cx| {
if then.is_some() {
host.close_cancelled(cx);
}
host.dialog_done(window, cx)
})
};
Card::new("office-conflict", title, cancelled)
.detail(text(cx, "office.conflict.detail", &[]))
.action(cancel(cx))
.action(choice(
&this,
"office-conflict-reload",
text(cx, "office.conflict.reload", &[]),
ButtonVariant::Secondary,
move |host, _, cx| {
// Reloading ends the close that waited.
if then.is_some() {
host.close_cancelled(cx);
}
host.reload(tab, cx)
},
))
.action(choice(
&this,
"office-conflict-copy",
text(cx, "office.conflict.copy", &[]),
ButtonVariant::Secondary,
move |host, _, cx| host.save_as(tab, then, cx),
))
.action(choice(
&this,
"office-conflict-keep",
text(cx, "office.conflict.keep", &[]),
ButtonVariant::Primary,
move |host, window, cx| host.keep_mine(tab, then, window, cx),
))
}
};
Some(card.into_any_element())
}
}
impl<H: Host> Dialogs for H {}
/// A dialog's Cancel button.
pub fn cancel(cx: &App) -> impl FnOnce(Close) -> Button + 'static {
let label = text(cx, "office.cancel", &[]);
move |close| {
Button::new("office-dialog-cancel", label).on_click(move |_, window, cx| close(window, cx))
}
}
/// A dialog button that closes the dialog, then runs `run`.
fn choice<H: Host>(
this: &Entity<H>,
id: &'static str,
label: SharedString,
variant: ButtonVariant,
run: impl Fn(&mut H, &mut Window, &mut Context<H>) + 'static,
) -> impl FnOnce(Close) -> Button + 'static {
let this = this.clone();
let run = Rc::new(run);
move |_| {
Button::new(id, label)
.variant(variant)
.on_click(move |_, window, cx| {
let run = run.clone();
this.update(cx, |host, cx| {
host.core_mut().dialog = None;
run(host, window, cx);
if host.core().dialog.is_none() {
host.dialog_done(window, cx);
}
})
})
}
}
/// The masked prompt for a locked file.
fn password<H: Host>(
this: Entity<H>,
(path, wrong, copy, field): (&PathBuf, bool, bool, &Entity<TextInput>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> PromptDialog {
let name = file_name(path);
let title = text(cx, "office.locked.title", &[("name", &name)]);
let empty = text(cx, "office.locked.empty", &[]);
// Each ask its own dialog, so focus is its own.
let id = ("office-locked", field.entity_id().as_u64());
let mut prompt = PromptDialog::new(id, title, field, dismiss)
.label(text(cx, "office.locked.label", &[]))
.submit(text(cx, "office.locked.open", &[]));
if wrong {
prompt = prompt.label(text(cx, "office.locked.wrong", &[]));
}
let path = path.clone();
prompt
.check(move |text| {
if text.is_empty() {
Err(empty.clone())
} else {
Ok(())
}
})
// The dialog closed first; the next ask may show.
.on_submit(move |text, _, cx| {
let (path, password) = (path.clone(), text.to_string());
this.update(cx, |host, cx| match copy {
true => host.load(Source::Locked(path, password), cx),
false => host.open_with(path, password, cx),
})
})
}
+89
View File
@@ -0,0 +1,89 @@
//! What a document engine gives the shell (§6).
use std::{
io,
path::Path,
sync::{Arc, Mutex},
};
use ely_platform::AppId;
use gpui::{App, Context, Entity, FocusHandle, Window};
use crate::Host;
/// Where a load starts.
pub enum Load<'a, I> {
/// A file and its password, empty for none.
File(&'a Path, &'a str),
/// Text bytes already at the path, read as told.
Import(&'a Path, &'a I),
}
/// A kernel behind the shell: loads, writes, history, views.
pub trait Engine: Sized + 'static {
type Worker: Send + 'static;
type Doc: Clone + Send + 'static;
type View: 'static;
/// What a load hands the window.
type Loaded: Send + 'static;
/// How text files are read.
type Import: Clone + Send + 'static;
/// Loads off the window; a locked file fails PermissionDenied.
fn load(worker: &mut Self::Worker, from: Load<Self::Import>) -> io::Result<Self::Loaded>;
/// The view over a load, its document and history mark.
fn open(
worker: &Arc<Mutex<Self::Worker>>,
loaded: Self::Loaded,
window: &mut Window,
cx: &mut App,
) -> io::Result<(Self::Doc, Entity<Self::View>, String)>;
/// Checked before the file is overwritten; a risk stops it.
fn unsound(_worker: &mut Self::Worker, _doc: &Self::Doc) -> io::Result<Option<crate::Risk>> {
Ok(None)
}
/// What saving in place would lose, macros aside.
fn risk(_loaded: &Self::Loaded) -> Option<crate::Risk> {
None
}
/// The undo history as a mark to compare.
fn history(worker: &mut Self::Worker, doc: &Self::Doc) -> io::Result<String>;
/// Writes `doc` as `format` to `path`.
fn write(
worker: &mut Self::Worker,
doc: &Self::Doc,
path: &Path,
format: &str,
) -> io::Result<()>;
/// A copy of `doc` at `path`; `doc` stays.
fn snapshot(_worker: &mut Self::Worker, _doc: &Self::Doc, _path: &Path, format: &str) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, format!("no snapshot as .{format}")))
}
fn close(worker: &mut Self::Worker, doc: Self::Doc) -> io::Result<()>;
/// A closing view's images, for the shell to free.
fn retire(view: &Entity<Self::View>, cx: &mut App) -> ely_gpui_component::primitives::Retired;
/// Why the worker ended, if it did (§6.3).
fn ended(_worker: &mut Self::Worker) -> Option<String> {
None
}
/// A fresh worker in place of an ended one.
fn restart(_app: AppId, _worker: &mut Self::Worker) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "this engine does not restart"))
}
/// Every input sent has reached the kernel.
fn settled(view: &Self::View) -> bool;
/// Edits landed so far.
fn edited(view: &Self::View) -> u64;
/// An edit is sent but not landed.
fn edit_pending(view: &Self::View) -> bool;
fn focus(view: &Self::View) -> FocusHandle;
/// The document was saved at history `mark`.
fn saved(view: &Entity<Self::View>, mark: &str, cx: &mut App);
/// Calls `reported` with the modified flag, `failed` with errors.
fn watch<H: Host<Engine = Self>>(
view: &Entity<Self::View>,
cx: &mut Context<H>,
reported: impl Fn(&mut H, &Entity<Self::View>, bool, &mut Context<H>) + 'static,
failed: impl Fn(&mut H, &str, &mut Context<H>) + 'static,
);
}
+264
View File
@@ -0,0 +1,264 @@
//! Formats and the kernel half of a save (FILE-04).
use std::{
io,
path::{Path, PathBuf},
};
use gpui::SharedString;
use quick_xml::{
events::Event,
name::{Namespace, ResolveResult},
reader::NsReader,
};
use crate::{Engine, Kind};
/// The lowercase extension of `path`.
fn extension(path: &Path) -> String {
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_lowercase()
}
/// The format a save of `path` writes: its own (FILE-04).
pub fn kept_format(kind: &Kind, path: &Path) -> io::Result<&'static str> {
let ext = extension(path);
kind.kept
.iter()
.find(|kept| **kept == ext)
.copied()
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, format!("cannot save .{ext}")))
}
/// The format a Save As to `path` writes, or None.
pub fn save_as_format(kind: &Kind, path: &Path) -> Option<&'static str> {
let ext = extension(path);
kind.save_as.iter().find(|format| **format == ext).copied()
}
/// The kernel writes `format` into `work`; the bytes come back.
pub fn kernel_bytes<G: Engine>(
worker: &mut G::Worker,
doc: &G::Doc,
work: &Path,
format: &str,
) -> io::Result<Vec<u8>> {
let copy = work.join(format!("save.{format}"));
G::write(worker, doc, &copy, format)?;
let bytes = std::fs::read(&copy)?;
log::info!("office: the kernel wrote {} bytes of {format}", bytes.len());
Ok(bytes)
}
/// Whether `path` carries macros Ely will not round-trip (LOK-11).
pub fn macro_risk(kind: &Kind, path: &Path) -> io::Result<bool> {
let Some(macros) = kind.macros else {
return Ok(false);
};
if macros.contains(&extension(path).as_str()) {
return Ok(true);
}
let bytes = std::fs::read(path)?;
if bytes.starts_with(b"PK\x03\x04") {
let names = zip_names(&bytes)?;
return Ok(names
.iter()
.any(|name| name.ends_with("vbaProject.bin") || name.starts_with("Basic/")));
}
if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
return Ok(ole_has_vba(&bytes));
}
flat_odf_has_basic(&bytes)
}
/// The office namespace that carries Basic libraries.
const OOO: &[u8] = b"http://openoffice.org/2004/office";
/// Whether flat ODF embeds a Basic library, namespaces in scope.
fn flat_odf_has_basic(bytes: &[u8]) -> io::Result<bool> {
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
if !text.trim_ascii_start().starts_with(b"<") {
return Ok(false);
}
let mut reader = NsReader::from_reader(text);
let mut buf = Vec::new();
loop {
let (bound, event) = reader
.read_resolved_event_into(&mut buf)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
match (bound, event) {
(ResolveResult::Bound(Namespace(OOO)), Event::Start(tag) | Event::Empty(tag))
if tag.local_name().as_ref() == b"library-embedded" =>
{
return Ok(true);
}
(_, Event::Eof) => return Ok(false),
_ => {}
}
buf.clear();
}
}
/// The entry names in a zip's central directory.
fn zip_names(bytes: &[u8]) -> io::Result<Vec<String>> {
let bad = || io::Error::new(io::ErrorKind::InvalidData, "a damaged zip directory");
let u16_at = |at: usize| {
bytes
.get(at..at + 2)
.map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)
};
let u32_at = |at: usize| {
bytes
.get(at..at + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize)
};
let end = (0..bytes.len().saturating_sub(21))
.rev()
.find(|&at| bytes[at..].starts_with(b"PK\x05\x06"))
.ok_or_else(bad)?;
let (count, mut at) = (
u16_at(end + 10).ok_or_else(bad)?,
u32_at(end + 16).ok_or_else(bad)?,
);
let mut names = Vec::with_capacity(count);
for _ in 0..count {
if !bytes
.get(at..)
.is_some_and(|rest| rest.starts_with(b"PK\x01\x02"))
{
return Err(bad());
}
let name_len = u16_at(at + 28).ok_or_else(bad)?;
let skip = u16_at(at + 30).ok_or_else(bad)? + u16_at(at + 32).ok_or_else(bad)?;
let name = bytes.get(at + 46..at + 46 + name_len).ok_or_else(bad)?;
names.push(String::from_utf8_lossy(name).into_owned());
at += 46 + name_len + skip;
}
Ok(names)
}
/// Whether an OLE file has a `_VBA_PROJECT` directory entry.
fn ole_has_vba(bytes: &[u8]) -> bool {
let name: Vec<u8> = "_VBA_PROJECT"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
// 128-byte entries: name, byte length, type 2.
(512..bytes.len().saturating_sub(128))
.step_by(128)
.any(|at| {
let entry = &bytes[at..at + 128];
entry.starts_with(&name)
&& u16::from_le_bytes([entry[64], entry[65]]) as usize == name.len() + 2
&& entry[66] == 2
})
}
/// A path's file name for tab titles.
pub fn file_name(path: &Path) -> SharedString {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
.into()
}
/// A fresh scratch folder for one document's kernel copies.
pub fn work_dir(root: &Path, id: u64) -> io::Result<PathBuf> {
let dir = root.join(format!("doc-{id}"));
if dir.try_exists()? {
std::fs::remove_dir_all(&dir)?;
}
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
const KIND: Kind = Kind {
app: ely_platform::AppId::Doc,
kept: &["docx", "odt", "doc"],
save_as: &["docx", "odt"],
macros: Some(&["docm", "dotm"]),
};
#[test]
fn saves_keep_the_format_and_save_as_converts() {
let kept = |path: &str| kept_format(&KIND, Path::new(path));
assert_eq!(kept("/a/Contract.DOC").expect("doc"), "doc");
assert_eq!(kept("/a/b.odt").expect("odt"), "odt");
assert!(kept("/a/b.pages").is_err(), "unknown formats fail");
assert_eq!(save_as_format(&KIND, Path::new("/a/b.docx")), Some("docx"));
assert_eq!(save_as_format(&KIND, Path::new("/a/b.pdf")), None);
}
#[test]
fn macros_are_found_by_structure_never_by_text() {
let dir = std::env::temp_dir().join(format!("ely-office-risk-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a dir");
let write = |name: &str, body: &[u8]| {
let path = dir.join(name);
std::fs::write(&path, body).expect("writes");
path
};
assert!(macro_risk(&KIND, &write("a.docm", b"PK")).expect("docm"));
let notes = write(
"notes.txt",
b"The archive path is Basic/Standard/Module1.xml and vbaProject.bin",
);
assert!(
!macro_risk(&KIND, &notes).expect("text"),
"words in text are not macros"
);
let example = write(
"xml-example.txt",
b"Example: <ooo:library-embedded ooo:name=\"Standard\">",
);
assert!(
!macro_risk(&KIND, &example).expect("text with a tag"),
"unbound tags are text"
);
let root = |prefix: &str| {
format!(
"<office:document xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:{prefix}=\"http://openoffice.org/2004/office\">"
)
};
let basic = format!(
"{}<ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"/>",
root("ooo")
);
assert!(macro_risk(&KIND, &write("f.fodt", basic.as_bytes())).expect("flat basic"));
let alias = format!(
"{}<basic:libraries><basic:library-embedded basic:name=\"Standard\"/>",
root("basic")
);
assert!(
macro_risk(&KIND, &write("g.fodt", alias.as_bytes())).expect("aliased"),
"any bound prefix counts"
);
let nested = format!(
"{}<office:scripts><office:script xmlns:x=\"http://openoffice.org/2004/office\"><x:libraries><x:library-embedded x:name=\"Standard\"/>",
root("ooo")
);
assert!(
macro_risk(&KIND, &write("n.fods", nested.as_bytes())).expect("nested"),
"a child may declare the namespace"
);
let shadowed = format!(
"{}<ooo:p xmlns:ooo=\"urn:other\"><ooo:library-embedded/></ooo:p>",
root("ooo")
);
assert!(
!macro_risk(&KIND, &write("s.fodt", shadowed.as_bytes())).expect("shadowed"),
"a prefix rebound elsewhere is not Basic"
);
assert!(
macro_risk(&KIND, &write("bad.fodt", b"<office:document><a></b>")).is_err(),
"broken XML is an error"
);
std::fs::remove_dir_all(&dir).expect("cleans");
}
}
+384
View File
@@ -0,0 +1,384 @@
//! Find and replace over the active document (DOC-12, TABLE-08).
use std::collections::VecDeque;
use ely_gpui_component::{
editor::{FindOptions, FindWidget},
forms::{InputEvent, TextInput},
primitives::{Severity, raise},
};
use ely_lok::{LokView, Rect};
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement, ParentElement as _, Styled as _, Subscription, Window, actions, div,
};
use crate::{Commands as _, Host, Tabs as _, text};
actions!(office, [FindReplace]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Asked {
All,
Step,
}
/// The open find box and what the kernel last found.
pub struct Finder {
/// The tab it searches.
tab: u64,
pub query: Entity<TextInput>,
pub replace: Option<Entity<TextInput>>,
pub options: FindOptions,
/// Every match from the last count, in order.
pub all: Vec<Rect>,
pub current: Option<usize>,
/// The view's search count already taken.
seen: u64,
/// Requests still to answer; a newer query voids older ones.
pub pending: VecDeque<(Asked, bool)>,
/// Steps waiting for a count to land.
pub queued: VecDeque<bool>,
/// The first step marked the place for Back.
left: bool,
/// The view's edit count and undo history last seen.
edited: u64,
history: Option<String>,
/// The edit count of the newest history check applied.
checked: u64,
/// The document changed since the count.
pub stale: bool,
_query: Subscription,
}
pub trait Finding: Host<Engine = crate::Lok> {
fn on_find(&mut self, _: &FindReplace, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let open = self.core().finder.as_ref();
if let Some(finder) = open.filter(|finder| finder.tab == tab.id) {
return window.focus(&finder.query.focus_handle(cx), cx);
}
let (id, seen) = (tab.id, tab.view.read(cx).search().0);
let edited = tab.view.read(cx).edited();
let query = cx.new(|cx| TextInput::new(window, cx));
let subscription = cx.subscribe(&query, |host, _, event, cx| match event {
InputEvent::Changed => host.count_matches(cx),
InputEvent::Submit => host.step(true, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
self.core_mut().finder = Some(Finder {
tab: id,
query,
replace: None,
options: FindOptions::default(),
all: Vec::new(),
current: None,
seen,
pending: VecDeque::new(),
queued: VecDeque::new(),
left: false,
edited,
history: None,
checked: edited,
stale: false,
_query: subscription,
});
self.check_history(id, edited, false, cx);
cx.notify();
}
/// Checks the undo history in the background; moves mark stale.
fn check_history(&mut self, id: u64, edited: u64, counted: bool, cx: &mut Context<Self>) {
self.read_in(
id,
|worker, doc| worker.values(doc, ".uno:Undo"),
move |host, read, _, cx| {
let history = match read {
Ok(history) => history,
Err(error) => return host.find_failed(&error, cx),
};
let slug = Self::KIND.app.slug();
let open = host.core_mut().finder.as_mut();
let Some(finder) = open.filter(|finder| finder.tab == id) else {
return;
};
// A check for older edits never overrides a newer one.
if edited < finder.checked {
return;
}
let moved = finder.history.as_ref().is_some_and(|old| *old != history);
if counted {
finder.stale = false;
} else if moved && edited > finder.checked {
log::info!("{slug}: the text changed under the finder");
finder.stale = true;
}
(finder.checked, finder.history) = (edited, Some(history));
cx.notify();
},
cx,
);
}
/// The query and its options as `find.run` values.
fn search_values(&self, cx: &Context<Self>, command: &str, back: bool) -> Option<Vec<String>> {
let finder = self.here()?;
let query = finder.query.read(cx).text().to_string();
if query.is_empty() {
return None;
}
let options = finder.options;
let flag = |on: bool, yes: &str, no: &str| if on { yes } else { no }.to_string();
Some(vec![
query,
back.to_string(),
command.to_string(),
flag(options.case, "0", "256"),
flag(options.word, "16", "0"),
flag(options.regex, "2", "1"),
])
}
fn run_search(
&mut self,
id: &str,
values: Vec<String>,
asked: Option<Asked>,
cx: &mut Context<Self>,
) {
if let Some((finder, asked)) = self.core_mut().finder.as_mut().zip(asked) {
finder.pending.push_back((asked, true));
}
let values: Vec<&str> = values.iter().map(String::as_str).collect();
self.run_active(id, &values, cx);
}
fn find_failed(&self, error: &std::io::Error, cx: &mut Context<Self>) {
let text = text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
/// Counts every match; the kernel highlights them.
fn count_matches(&mut self, cx: &mut Context<Self>) {
if let Some(finder) = self.core_mut().finder.as_mut() {
finder
.pending
.iter_mut()
.for_each(|(_, live)| *live = false);
finder.queued.clear();
}
let Some(values) = self.search_values(cx, "1", false) else {
if let Some(finder) = self.core_mut().finder.as_mut() {
finder.all.clear();
finder.current = None;
}
return cx.notify();
};
self.run_search("find.run", values, Some(Asked::All), cx);
}
fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
let Some(finder) = self.here() else {
return;
};
if finder.stale {
return self.count_matches(cx);
}
if counting(finder) {
let finder = self.core_mut().finder.as_mut().expect("the finder is open");
finder.queued.push_back(forward);
return;
}
if let Some(values) = self.search_values(cx, "0", !forward) {
if !std::mem::replace(
&mut self.core_mut().finder.as_mut().expect("open").left,
true,
) {
self.search_leaves(cx);
}
self.run_search("find.run", values, Some(Asked::Step), cx);
}
}
/// Replaces the chosen match, or every one; then counts again.
fn replace(&mut self, every: bool, cx: &mut Context<Self>) {
let Some(finder) = self.here() else {
return;
};
let Some(field) = &finder.replace else {
return;
};
if finder.stale {
return self.count_matches(cx);
}
if !every && finder.current.is_none() {
return self.step(true, cx);
}
let with = field.read(cx).text().to_string();
let Some(mut values) = self.search_values(cx, if every { "3" } else { "2" }, false) else {
return;
};
values.push(with);
// A replace reports only when nothing is left.
self.run_search("find.replace", values, None, cx);
self.count_matches(cx);
}
/// Takes a search outcome or an edit.
fn found(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let Some(finder) = self
.core()
.finder
.as_ref()
.filter(|finder| finder.tab == id)
else {
return;
};
let edited = view.read(cx).edited();
let (count, found) = {
let (count, found) = view.read(cx).search();
(count, found.to_vec())
};
let outcomes = usize::try_from(count - finder.seen).expect("a count of outcomes");
let asked = (0..outcomes)
.fold(None, |last, _| {
let finder = self.core_mut().finder.as_mut().expect("the finder is open");
let next = finder.pending.pop_front();
next.map(|(asked, live)| live.then_some(asked)).or(last)
})
.flatten();
let finder = self.core().finder.as_ref().expect("the finder is open");
let counted = asked == Some(Asked::All);
if edited != finder.edited || counted {
let finder = self.core_mut().finder.as_mut().expect("the finder is open");
finder.edited = edited;
// A fresh count is current now; older checks drop.
if counted {
(finder.stale, finder.checked) = (false, edited);
}
self.check_history(id, edited, counted, cx);
}
let finder = self.core_mut().finder.as_mut().expect("the finder is open");
if count != finder.seen {
finder.seen = count;
match (asked, found.first()) {
(Some(Asked::All), _) | (Some(Asked::Step), None) => {
finder.all = found.clone();
finder.current = None;
}
(Some(Asked::Step), Some(first)) => {
finder.current = finder.all.iter().position(|rect| rect == first);
let first = *first;
view.update(cx, |view, cx| view.show(first, cx));
}
(None, _) => log::info!(
"{}: a search outcome nobody asked for",
Self::KIND.app.slug()
),
}
log::info!(
"{}: {} matches, at {:?}",
Self::KIND.app.slug(),
finder.all.len(),
finder.current
);
}
let finder = self.core_mut().finder.as_mut().expect("the finder is open");
let chosen = finder.current.map(|at| finder.all[at]);
if chosen.is_some() && view.read(cx).selection().first() != chosen.as_ref() {
finder.current = None;
}
if !counting(finder) {
for forward in std::mem::take(&mut finder.queued) {
self.step(forward, cx);
}
}
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.core_mut().finder = None;
if let Some(tab) = self.active_tab() {
window.focus(&tab.view.read(cx).focus_handle().clone(), cx);
}
cx.notify();
}
/// The finder, when it searches the active tab.
fn here(&self) -> Option<&Finder> {
self.core()
.finder
.as_ref()
.filter(|finder| Some(finder.tab) == self.core().active)
}
/// The find box over the page, while open.
fn find_box(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let finder = self.here()?;
let this = cx.entity();
let mut widget = FindWidget::new(
"office-find",
&finder.query,
finder.current,
finder.all.len(),
)
.options(finder.options)
.on_options({
let this = this.clone();
move |options, _, cx| {
this.update(cx, |host, cx| {
if let Some(finder) = host.core_mut().finder.as_mut() {
finder.options = options;
}
host.count_matches(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, _, cx| this.update(cx, |host, cx| host.step(forward, cx))
})
.on_replace({
let this = this.clone();
move |every, _, cx| this.update(cx, |host, cx| host.replace(every, cx))
})
.on_toggle_replace({
let this = this.clone();
move |show, window, cx| {
this.update(cx, |host, cx| {
if let Some(finder) = host.core_mut().finder.as_mut() {
finder.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx)));
}
cx.notify();
})
}
})
.on_close(move |window, cx| this.update(cx, |host, cx| host.close_find(window, cx)));
if let Some(field) = &finder.replace {
widget = widget.replace(field);
}
if finder.stale {
widget = widget.error(text(cx, "office.find.stale", &[]));
}
let place = div()
.absolute()
.top_2()
.right_4()
.block_mouse_except_scroll()
.child(widget);
Some(raise("office-find-layer", place).into_any_element())
}
}
impl<H: Host<Engine = crate::Lok>> Finding for H {}
/// Whether a live count is still on its way.
fn counting(finder: &Finder) -> bool {
finder
.pending
.iter()
.any(|(asked, live)| *live && *asked == Asked::All)
}
+380
View File
@@ -0,0 +1,380 @@
//! 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;
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 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: Host>(
pdf: std::path::PathBuf,
cx: &mut Context<H>,
failed: impl FnOnce(&mut H, String, &mut Context<H>) + 'static,
) {
// The panel's modal loop calls back into GPUI.
cx.spawn(async move |this, cx| {
let printed = ely_platform::print::print_pdf(&pdf);
// 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| failed(host, error.to_string(), cx))
.is_err()
{
log::error!("office: printing failed after its window closed: {error}");
}
}
}
})
.detach();
}
/// 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);
}
/// 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 {}
+227
View File
@@ -0,0 +1,227 @@
//! LibreOfficeKit as the shell's engine (§7).
use std::{
io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use ely_lok::{Doc, LokError, LokView, Reported, Worker, sheet::csv::Import};
use ely_platform::AppId;
use gpui::{App, Context, Entity, FocusHandle, Window};
use crate::engine::{Engine, Load};
use crate::{Host, Tabs, text};
/// The LOK engine.
pub struct Lok;
/// A load: the document, its layout, its history.
pub struct Loaded {
doc: Doc,
laid: ely_lok::Layout,
mark: String,
}
impl Engine for Lok {
type Worker = Worker;
type Doc = Doc;
type View = LokView;
type Loaded = Loaded;
type Import = Import;
fn load(worker: &mut Worker, from: Load<Import>) -> io::Result<Loaded> {
let doc = match from {
Load::File(path, password) => worker.unlock(&ely_lok::file_url(path), password)?,
Load::Import(path, how) => worker.import(&ely_lok::file_url(path), how)?,
};
// Layout and history too: the window never waits.
let laid = ely_lok::layout(worker, &doc)?;
let mark = worker.values(&doc, ".uno:Undo")?;
Ok(Loaded { doc, laid, mark })
}
fn open(
worker: &Arc<Mutex<Worker>>,
loaded: Loaded,
_: &mut Window,
cx: &mut App,
) -> io::Result<(Doc, Entity<LokView>, String)> {
let Loaded { doc, laid, mark } = loaded;
let view = LokView::open(Arc::clone(worker), doc.clone(), laid, cx);
Ok((doc, view, mark))
}
fn history(worker: &mut Worker, doc: &Doc) -> io::Result<String> {
worker.values(doc, ".uno:Undo")
}
fn write(worker: &mut Worker, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
doc.save_owned(worker, &ely_lok::file_url(path), format)
}
fn snapshot(worker: &mut Worker, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
doc.save_as(worker, &ely_lok::file_url(path), format)
}
fn close(worker: &mut Worker, doc: Doc) -> io::Result<()> {
worker.close_doc(doc)
}
fn retire(view: &Entity<LokView>, cx: &mut App) -> ely_gpui_component::primitives::Retired {
view.update(cx, |view, _| view.retire_all())
}
fn ended(worker: &mut Worker) -> Option<String> {
worker.ended()
}
fn restart(app: AppId, worker: &mut Worker) -> io::Result<()> {
*worker = spawn(app)?;
worker.ready()?;
worker.set_author(&ely_platform::user::name()?)
}
fn settled(view: &LokView) -> bool {
view.settled()
}
fn edited(view: &LokView) -> u64 {
view.edited()
}
fn edit_pending(view: &LokView) -> bool {
view.edit_pending()
}
fn focus(view: &LokView) -> FocusHandle {
view.focus_handle().clone()
}
/// The kernel reports its own modified status.
fn saved(_: &Entity<LokView>, _: &str, _: &mut App) {}
fn watch<H: Host<Engine = Self>>(
view: &Entity<LokView>,
cx: &mut Context<H>,
reported: impl Fn(&mut H, &Entity<LokView>, bool, &mut Context<H>) + 'static,
failed: impl Fn(&mut H, &str, &mut Context<H>) + 'static,
) {
cx.subscribe(view, move |host, _, error: &LokError, cx| match error.0.starts_with(ely_lok::STALE) {
true => failed(host, &text(cx, "office.error.changed", &[]), cx),
false => failed(host, &error.0, cx),
})
.detach();
// The status shows the zoom.
cx.subscribe(view, |_, _, _: &ely_lok::Zoomed, cx| cx.notify()).detach();
cx.subscribe(view, move |host, view, _: &Reported, cx| {
let modified = view.read(cx).state(".uno:ModifiedStatus") == Some("true");
reported(host, &view, modified, cx)
})
.detach();
}
}
/// The worker and the scratch root of `app`.
pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBuf)> {
let data = ely_platform::data_dir(app).map_err(io::Error::other)?;
let work = crate::fresh_work(&data)?;
let worker = Arc::new(Mutex::new(spawn(app)?));
// The window opens now; the kernel finishes starting behind it.
let starting = Arc::clone(&worker);
let started = cx.background_executor().spawn(async move {
let mut worker = starting.lock().expect("the worker lock");
worker.ready()?;
worker.set_author(&ely_platform::user::name()?)
});
cx.spawn(async move |_| {
if let Err(error) = started.await {
ely_platform::startup::fail(app, &format!("the document engine did not start: {error}"));
}
})
.detach();
follow_author(&worker, cx);
Ok((worker, work))
}
/// A worker process on the app's profile, not yet checked.
fn spawn(app: AppId) -> io::Result<Worker> {
let data = ely_platform::data_dir(app).map_err(io::Error::other)?;
let install = ely_lok::install_dir()?;
let worker = Worker::launch(&install, &data.join("lok-profile"))?;
log::info!("{}: worker from {}", app.slug(), install.display());
Ok(worker)
}
/// A new author name signs from the next comment on.
fn follow_author(worker: &Arc<Mutex<Worker>>, cx: &mut App) {
let worker = Arc::clone(worker);
let mut signed = ely_ui::settings::current(cx).author.clone();
cx.observe_global::<ely_ui::settings::Current>(move |cx| {
let author = ely_ui::settings::current(cx).author.clone();
if author == signed {
return;
}
signed = author;
let worker = Arc::clone(&worker);
cx.background_executor()
.spawn(async move {
let set = ely_platform::user::name().and_then(|name| worker.lock().expect("the worker lock").set_author(&name));
if let Err(error) = set {
log::error!("lok: the new author name is not set: {error}");
}
})
.detach();
})
.detach();
}
/// Mapped UNO commands on LOK tabs.
pub trait Commands: Host<Engine = Lok> {
/// Runs a mapped command on the active tab.
fn run_active(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
self.run_in(self.core().active, id, values, cx);
}
/// Runs a mapped command on tab `tab`.
fn run_in(&mut self, tab: Option<u64>, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(tab) = tab.and_then(|tab| self.tab(tab)) else {
let text = text(cx, "command.blocked.no_document", &[]);
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
};
log::info!("{}: runs {id}", Self::KIND.app.slug());
// Numbers hold only in the state they were read.
let addressed = ely_lok::commands::find(id).is_ok_and(|command| command.addressed(values));
let sent = match (addressed, self.read_at(tab.id, cx)) {
(true, Some(at)) => tab.view.update(cx, |view, _| view.run_at(&at, id, values)),
_ => tab.view.update(cx, |view, _| view.run(id, values)),
};
if let Err(error) = sent {
self.engine_failed(&error.to_string(), cx);
}
}
/// Runs `commands` made from tab `tab`'s last read, as one.
fn run_all_in(&mut self, tab: u64, commands: &[(&str, &[&str])], cx: &mut Context<Self>) {
let (Some(open), Some(at)) = (self.tab(tab), self.read_at(tab, cx)) else {
return log::warn!("{}: tab {tab} has no read to run from", Self::KIND.app.slug());
};
log::info!("{}: runs {} commands from one read", Self::KIND.app.slug(), commands.len());
if let Err(error) = open.view.update(cx, |view, _| view.run_all_at(&at, commands)) {
self.engine_failed(&error.to_string(), cx);
}
}
/// Runs a numbered command against the read state `at`.
fn run_on(&mut self, tab: u64, at: &Doc, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(tab) = self.tab(tab) else {
return;
};
log::info!("{}: runs {id} at a read state", Self::KIND.app.slug());
if let Err(error) = tab.view.update(cx, |view, _| view.run_at(at, id, values)) {
self.engine_failed(&error.to_string(), cx);
}
}
}
impl<H: Host<Engine = Lok>> Commands for H {}
+280
View File
@@ -0,0 +1,280 @@
//! Opening user files and copies over one worker (FILE-03).
use std::{
io,
path::{Path, PathBuf},
sync::Arc,
};
use ely_files::{Open, Session};
use gpui::{AppContext as _, Context, Entity, TaskExt as _, Window};
use crate::engine::{Engine, Load};
use crate::files::{self, file_name};
use crate::{Host, Tab, Tabs, View, in_window, text};
/// How host `H`'s engine reads text files.
type ImportOf<H> = <<H as Host>::Engine as Engine>::Import;
/// What a load starts from.
pub enum Source<I> {
/// A user file under its registry id, with its password.
File(PathBuf, usize, String),
/// A template copy; the result is untitled.
Copy(PathBuf),
/// A copy that needed a password, with the one given.
Locked(PathBuf, String),
/// Checked text bytes, where they go, how to read.
Import(PathBuf, Arc<Vec<u8>>, I),
}
pub trait Opening: Host {
/// An untitled copy of a local template.
fn new_from_file(&mut self, template: &Path, cx: &mut Context<Self>) {
let name = file_name(template);
let copy = files::work_dir(&self.core().work, self.core().next).and_then(|dir| {
let copy = dir.join(name.as_ref());
std::fs::copy(template, &copy)?;
Ok(copy)
});
match copy {
Ok(copy) => self.load(Source::Copy(copy), cx),
Err(error) => self.failed_open(&name, &error, cx),
}
}
/// A new document from checked text bytes named `name`.
fn import_text(
&mut self,
name: &str,
bytes: Arc<Vec<u8>>,
how: ImportOf<Self>,
cx: &mut Context<Self>,
) {
match files::work_dir(&self.core().work, self.core().next) {
Ok(dir) => self.load(Source::Import(dir.join(name), bytes, how), cx),
Err(error) => self.failed_open(name, &error, cx),
}
}
/// An untitled document from `contents` named `name`.
fn new_from_text(&mut self, name: &str, contents: &str, cx: &mut Context<Self>) {
let copy = files::work_dir(&self.core().work, self.core().next).and_then(|dir| {
let copy = dir.join(name);
std::fs::write(&copy, contents)?;
Ok(copy)
});
match copy {
Ok(copy) => self.load(Source::Copy(copy), cx),
Err(error) => self.failed_open(name, &error, cx),
}
}
/// Opens a user file; an open one gets focus (FILE-03).
fn open_file(&mut self, path: PathBuf, cx: &mut Context<Self>) {
self.open_with(path, String::new(), cx);
}
/// Opens a user file with its password.
fn open_with(&mut self, path: PathBuf, password: String, cx: &mut Context<Self>) {
match self.core_mut().sessions.open(&path, false) {
Ok(Open::Focus(registry)) => {
let core = self.core_mut();
core.active = core
.tabs
.iter()
.find(|tab| tab.registry == Some(registry))
.map(|tab| tab.id);
cx.notify();
}
Ok(Open::Fresh(registry) | Open::View(registry)) => {
self.load(Source::File(path, registry, password), cx)
}
Err(error) => self.failed_open(&path.display().to_string(), &error, cx),
}
}
fn failed_open(&self, name: &str, error: &io::Error, cx: &mut gpui::App) {
log::error!("{}: {name} did not open: {error}", Self::KIND.app.slug());
let error = error.to_string();
let text = text(
cx,
"office.error.open",
&[("name", name), ("error", &error)],
);
self.toast(text, ely_gpui_component::primitives::Severity::Danger, cx);
}
/// Loads off the main thread; the tab takes focus.
fn load(&mut self, source: Source<ImportOf<Self>>, cx: &mut Context<Self>) {
let id = self.core().next;
self.core_mut().next += 1;
self.load_into(id, source, cx);
}
/// Loads into tab id `id`, taken earlier from `next`.
fn load_into(&mut self, id: u64, source: Source<ImportOf<Self>>, cx: &mut Context<Self>) {
let (path, password, text) = match &source {
Source::File(path, _, password) => (path.clone(), password.clone(), None),
Source::Copy(path) => (path.clone(), String::new(), None),
Source::Locked(path, password) => (path.clone(), password.clone(), None),
Source::Import(path, bytes, how) => (
path.clone(),
String::new(),
Some((Arc::clone(bytes), how.clone())),
),
};
let written = path.clone();
self.core_mut().loading.push((id, file_name(&path)));
cx.notify();
let worker = Arc::clone(&self.core().worker);
cx.spawn(async move |this, cx| {
let loaded = cx
.background_spawn(async move {
if let Some((bytes, _)) = &text {
std::fs::write(&written, bytes.as_slice())?;
}
let mut worker = worker.lock().expect("the worker lock");
let from = match &text {
None => Load::File(&written, &password),
Some((_, how)) => Load::Import(&written, how),
};
Self::Engine::load(&mut worker, from)
})
.await;
in_window(&this, cx, |host, window, cx| {
host.loaded(id, source, loaded, window, cx)
})
})
.detach_and_log_err(cx);
}
fn loaded(
&mut self,
id: u64,
source: Source<ImportOf<Self>>,
loaded: io::Result<<Self::Engine as Engine>::Loaded>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.core_mut()
.loading
.retain(|(loading, _)| *loading != id);
let imported = matches!(source, Source::Import(..));
let (path, registry, tried) = match source {
Source::File(path, registry, password) => (path, Some(registry), !password.is_empty()),
Source::Copy(path) | Source::Import(path, ..) => (path, None, false),
Source::Locked(path, _) => (path, None, true),
};
let opened = loaded.and_then(|loaded| {
let risk = match files::macro_risk(&Self::KIND, &path)? {
true => Some(crate::Risk::Macros),
false => Self::Engine::risk(&loaded),
};
let (work, session) = match registry {
Some(_) => (
files::work_dir(&self.core().work, id)?,
Session::load(path.clone())?,
),
None => {
let dir = path.parent().expect("a copy sits in its work dir");
(dir.to_path_buf(), Session::new_file())
}
};
let (doc, view, mark) = Self::Engine::open(&self.core().worker, loaded, window, cx)?;
Ok((doc, work, session, risk, view, mark))
});
let (doc, work, session, risk, view, mark) = match opened {
Ok(opened) => opened,
Err(error) => {
if let Some(registry) = registry {
self.core_mut().sessions.close(registry);
}
cx.notify();
// A locked copy asks too, and stays a copy.
if error.kind() == io::ErrorKind::PermissionDenied && !imported {
return crate::Dialogs::ask_password(self, path, tried, registry.is_none(), window, cx);
}
return self.failed_open(&path.display().to_string(), &error, cx);
}
};
let name = match registry {
Some(_) => file_name(&path),
None if imported => file_name(&path),
None => {
self.core_mut().untitled += 1;
let n = self.core().untitled.to_string();
text(cx, "office.untitled", &[("n", &n)])
}
};
log::info!(
"{}: opened {name} as tab {id}, risk {risk:?}",
Self::KIND.app.slug()
);
self.watch(id, &view, cx);
window.focus(&Self::Engine::focus(view.read(cx)), cx);
self.core_mut().tabs.push(Tab {
id,
view,
doc,
session,
registry,
name,
modified: false,
risk,
mark,
work,
exporting: false,
reopen: false,
extra: Default::default(),
});
self.core_mut().active = Some(id);
// Points from now on; a crash copy comes back unsaved.
let named = self.tab(id).expect("just opened").name.clone();
crate::Recovering::keep_tab(self, id, &named, tried, cx);
crate::Recovering::reopened(self, id, &path, cx);
cx.notify();
}
/// Engine errors become toasts; kernel edits mark the session.
fn watch(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>) {
let reported = move |host: &mut Self,
view: &Entity<View<Self>>,
modified: bool,
cx: &mut Context<Self>| {
if let Some(tab) = host.tab_mut(id) {
match (modified, tab.modified) {
(true, false) => tab.session.edit(),
(false, true) => tab.session.reverted(),
_ => {}
}
tab.modified = modified;
}
host.observed(id, view, cx);
if Self::Engine::settled(view.read(cx)) {
let (ready, rest): (Vec<_>, Vec<_>) = host
.core_mut()
.waiting
.drain(..)
.partition(|(tab, _)| *tab == id);
host.core_mut().waiting = rest;
for (_, then) in ready {
cx.spawn(async move |this, cx| in_window(&this, cx, then))
.detach_and_log_err(cx);
}
}
cx.notify();
};
Self::Engine::watch(view, cx, reported, move |host, error, cx| {
// A released tab's view may finish its queue late.
if host.tab(id).is_none() {
log::info!("office: tab {id} failed after it closed");
return;
}
host.engine_failed(error, cx);
crate::Stopping::check_engine(host, cx);
});
}
}
impl<H: Host> Opening for H {}
+285
View File
@@ -0,0 +1,285 @@
//! Kernel-bound controls the property panels share.
use ely_gpui_component::{
buttons::{Button, ToggleButton, ToggleItem},
forms::{Choice, ColorPalette, ColorSwatch, Combobox, Select, TextInput},
overlays::Popover,
primitives::{IconName, Tooltip},
theme::{ActiveTheme as _, ControlSize},
};
use ely_lok::show::Paint;
use gpui::{
AnyElement, App, Context, Entity, Hsla, InteractiveElement as _, IntoElement,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
rgb,
};
/// Sizes the size menu offers, in points.
const SIZES: [&str; 17] = [
"8", "9", "10", "10.5", "11", "12", "14", "16", "18", "20", "22", "24", "26", "28", "36", "48",
"72",
];
/// Palette colors, 0xRRGGBB.
pub const COLORS: [u32; 10] = [
0x000000, 0x808080, 0xFF0000, 0xFF8000, 0xFFFF00, 0x00A933, 0x00FFFF, 0x2A6099, 0x800080,
0xFFFFFF,
];
/// A panel that edits one document through table commands.
pub trait Panel: Sized + 'static {
/// The kernel's state for `uno`.
fn state(&self, uno: &str, cx: &App) -> Option<SharedString>;
/// Runs table command `id` on the document.
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>);
}
fn text(cx: &App, key: &str) -> SharedString {
ely_ui::i18n::text(cx, key, &[])
}
fn uno_of(command: &str) -> &'static str {
ely_lok::commands::find(command)
.expect("a table command")
.uno
}
/// Runs `command` with `values` through `panel`.
pub fn send<P: Panel>(panel: &Entity<P>, command: &'static str, values: &[&str], cx: &mut App) {
panel.update(cx, |panel, cx| panel.run(command, values, cx));
}
/// A kernel-lit toggle; `why` explains it when disabled.
pub fn toggle<P: Panel>(
panel: &P,
command: &'static str,
(icon, label): (Option<IconName>, &str),
(tip, why): (&str, &str),
cx: &Context<P>,
) -> AnyElement {
let this = cx.entity();
let state = panel.state(uno_of(command), cx);
let mut item = ToggleItem::new(command);
if let Some(icon) = icon {
item = item.icon(icon);
}
let shown = match state.as_deref() == Some(ely_lok::MIXED) {
true => format!("{label} {}", text(cx, "office.props.mixed")),
false => label.to_string(),
};
if !shown.trim().is_empty() {
item = item.label(shown.trim().to_string());
}
let disabled = state.as_deref() == Some("disabled");
if disabled {
item = item.tooltip(text(cx, why));
} else if !tip.is_empty() {
item = item.tooltip(text(cx, tip));
}
ToggleButton::new(
SharedString::from(command),
item,
state.as_deref() == Some("true"),
)
.disabled(disabled)
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| send(&this, command, &[], cx))
.into_any_element()
}
/// A button for `command`; `why` explains it when disabled.
pub fn button<P: Panel>(
panel: &P,
command: &'static str,
key: &str,
why: &str,
cx: &Context<P>,
) -> AnyElement {
let this = cx.entity();
let off = panel.state(uno_of(command), cx).as_deref() == Some("disabled");
let button = Button::new(SharedString::from(command), text(cx, key))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| send(&this, command, &[], cx));
guarded(button, command, off.then(|| text(cx, why)))
}
/// A clear button: label key, command, value.
pub type Clear = (&'static str, &'static str, &'static str);
/// A color swatch; `empty` names no color.
pub fn palette<P: Panel>(
panel: &Entity<P>,
command: &'static str,
(current, empty): (Paint, &str),
clear: Option<Clear>,
cx: &App,
) -> AnyElement {
let (shown, tip) = match current {
Paint::Color(color) => (Hsla::from(rgb(color)), format!("#{color:06X}").into()),
Paint::Mixed => (gpui::transparent_black(), text(cx, "office.props.mixed")),
Paint::None => (gpui::transparent_black(), text(cx, empty)),
};
let label = (current == Paint::Mixed).then(|| muted(tip.clone(), cx));
let clear = clear.map(|(key, run, value)| (text(cx, key), run, value));
let this = panel.clone();
let popover = Popover::with_opener(
SharedString::from(format!("{command}-popover")),
move |toggle| {
ColorSwatch::new(SharedString::from(format!("{command}-swatch")), shown)
.tooltip(tip)
.on_click(move |window, cx| toggle(window, cx))
},
move |close, _, _| {
let swatches = COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
let picked = this.clone();
let mut palette = ColorPalette::new(SharedString::from(command), swatches.clone())
.columns(5)
.on_change({
let close = close.clone();
move |color, window, cx| {
let at = swatches
.iter()
.position(|(_, swatch)| *swatch == color)
.expect("the palette hands back its own colors");
close(window, cx);
send(&picked, command, &[&COLORS[at].to_string()], cx)
}
});
if let Paint::Color(color) = current
&& COLORS.contains(&color)
{
palette = palette.selected(rgb(color));
}
let this = this.clone();
let clear = clear.clone().map(|(label, run, value)| {
Button::new(SharedString::from(format!("{command}-clear")), label)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
close(window, cx);
send(&this, run, &[value], cx)
})
});
div()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(palette)
.children(clear)
},
);
row([popover.into_any_element()].into_iter().chain(label))
}
/// The kernel's state for `uno` as a color.
pub fn color_state<P: Panel>(panel: &P, uno: &str, cx: &App) -> Paint {
let state = panel.state(uno, cx);
if state.as_deref() == Some(ely_lok::MIXED) {
return Paint::Mixed;
}
state
.and_then(|color| color.parse::<u32>().ok())
.map_or(Paint::None, Paint::Color)
}
/// The font box; `id` keys its text field.
pub fn font_box<P: Panel>(
panel: &P,
id: &'static str,
fonts: &[SharedString],
window: &mut Window,
cx: &mut Context<P>,
) -> AnyElement {
let this = cx.entity();
let input = window.use_keyed_state(
SharedString::from(format!("{id}-input")),
cx,
TextInput::new,
);
let choices = fonts
.iter()
.map(|font| Choice::new(font.clone(), font.clone()));
let mut font = Combobox::new(id, &input, choices)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| send(&this, "format.font", &[value.as_ref()], cx));
let name = panel.state(".uno:CharFontName", cx);
let hint = match name.as_deref() == Some(ely_lok::MIXED) {
true => text(cx, "office.props.mixed"),
false => SharedString::default(),
};
if input.read(cx).placeholder_text() != &hint {
input.update(cx, |input, cx| input.set_placeholder(hint, cx));
}
if let Some(name) = name.filter(|name| known(name)) {
font = font.selected(name);
}
font.into_any_element()
}
/// The size menu, in points.
pub fn size_menu<P: Panel>(panel: &P, id: &'static str, cx: &Context<P>) -> AnyElement {
let this = cx.entity();
let reported = panel.state(".uno:FontHeight", cx);
let size = reported.clone().filter(|size| known(size));
let mut sizes: Vec<SharedString> = SIZES.iter().map(|size| (*size).into()).collect();
if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) {
sizes.push(size);
}
sizes.sort_by(|a, b| points(a).total_cmp(&points(b)));
let mut menu = Select::new(
id,
sizes
.into_iter()
.map(|size| Choice::new(size.clone(), size)),
)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| send(&this, "format.size", &[value.as_ref()], cx));
if reported.as_deref() == Some(ely_lok::MIXED) {
menu = menu.placeholder(text(cx, "office.props.mixed"));
}
if let Some(size) = size {
menu = menu.selected(size);
}
menu.into_any_element()
}
/// Controls side by side, wrapping when narrow.
pub fn row(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
div()
.flex()
.flex_wrap()
.items_center()
.gap_1()
.children(items)
.into_any_element()
}
/// `button`, or it disabled with `why` on hover.
pub fn guarded(button: Button, id: &str, why: Option<SharedString>) -> AnyElement {
match why {
None => button.into_any_element(),
Some(why) => div()
.id(SharedString::from(format!("{id}-why")))
.child(button.disabled(true))
.tooltip(Tooltip::text(why))
.into_any_element(),
}
}
/// Quiet text in place of a control.
pub fn muted(text: SharedString, cx: &App) -> AnyElement {
div()
.text_color(cx.theme().colors.fg_muted)
.child(text)
.into_any_element()
}
/// A real kernel value: neither empty nor mixed.
pub fn known(state: &str) -> bool {
!state.is_empty() && state != ely_lok::MIXED
}
/// A size label's points, for ordering.
fn points(size: &str) -> f64 {
size.parse().expect("sizes are numbers")
}
+430
View File
@@ -0,0 +1,430 @@
//! Recovery points of open documents, back after crashes (§6.3).
use std::{
collections::HashMap,
io,
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use ely_files::{Crash, Marker, Recovery, RecoveryKey};
use ely_gpui_component::primitives::Severity;
use gpui::{AsyncApp, Context, WeakEntity};
use serde::{Deserialize, Serialize};
use crate::{Engine, Host, Opening as _, Source, Tabs as _, text};
/// A tab's points, and the edit the last holds.
struct Slot {
kept: Kept,
written: Option<u64>,
writing: bool,
}
/// What reopening a slot needs, kept beside its points.
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Kept {
slot: String,
name: String,
format: String,
sealed: bool,
/// Not reopened yet; a clean end keeps it.
#[serde(default)]
held: bool,
}
/// The app's recovery: marker, slots, what a crash left.
pub struct Keeping {
dir: PathBuf,
crash: Option<Crash>,
slots: HashMap<u64, Slot>,
crashed: Vec<Kept>,
/// Copies opening from points: path, point time, old slot.
reopening: Vec<(PathBuf, SystemTime, Kept)>,
/// Slots that could not reopen; kept for a later start.
held: Vec<Kept>,
/// When the last point was written.
pub(crate) last: Option<SystemTime>,
/// The index on disk is behind; the failure shown.
behind: Option<String>,
}
impl Keeping {
/// Takes the marker; a crash leaves its slots to reopen.
pub fn start(app: ely_platform::AppId) -> io::Result<Self> {
Self::start_in(ely_platform::data_dir(app).map_err(io::Error::other)?)
}
fn start_in(dir: PathBuf) -> io::Result<Self> {
let marker = Crash::check(&dir, ely_files::alive)?;
log::info!("recovery: the last run ended {marker:?}");
let listed = read_index(&dir)?;
let crashed = match marker {
Marker::Crashed => listed,
// A clean end keeps only what never reopened.
Marker::Clean | Marker::Discarded => {
let held: Vec<Kept> = listed.into_iter().filter(|kept| kept.held).collect();
prune(&dir.join("recovery"), &held)?;
held
}
};
let crash = Crash::open(&dir, std::process::id())?;
let (reopening, held) = (Vec::new(), Vec::new());
Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None })
}
/// A clean quit: the next start finds nothing to recover.
pub fn end(&mut self) {
if let Some(crash) = self.crash.take()
&& let Err(error) = crash.close()
{
log::error!("recovery: the clean-quit mark failed: {error}");
}
}
fn folder(&self, slot: &str) -> PathBuf {
self.dir.join("recovery").join(slot)
}
fn save_index(&self) -> io::Result<()> {
let waiting = self.reopening.iter().map(|(_, _, kept)| kept).chain(&self.held);
let waiting = waiting.map(|kept| Kept { held: true, ..kept.clone() });
let slots = self.slots.values().map(|slot| slot.kept.clone());
let kept: Vec<Kept> = slots.chain(self.crashed.iter().cloned()).chain(waiting).collect();
let path = self.dir.join("recovery").join("index.json");
std::fs::create_dir_all(self.dir.join("recovery"))?;
// Staged then renamed: a failed write keeps the last index.
ely_files::save_atomic(&path, &serde_json::to_vec(&kept).map_err(io::Error::other)?, 0).map(|_| ())
}
}
fn read_index(dir: &Path) -> io::Result<Vec<Kept>> {
match std::fs::read(dir.join("recovery").join("index.json")) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error),
}
}
/// Removes every slot folder but those `held`.
fn prune(recovery: &Path, held: &[Kept]) -> io::Result<()> {
if held.is_empty() {
return remove(recovery);
}
for entry in std::fs::read_dir(recovery)? {
let entry = entry?;
let name = entry.file_name();
if entry.file_type()?.is_dir() && !held.iter().any(|kept| *kept.slot == *name) {
remove(&entry.path())?;
}
}
Ok(())
}
fn remove(path: &Path) -> io::Result<()> {
match std::fs::remove_dir_all(path) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
}
/// When recovery points are written and crashes come back.
pub trait Recovering: Host {
/// From the app's window, never its tests.
fn start_recovery(&mut self, cx: &mut Context<Self>) {
match Keeping::start(Self::KIND.app) {
Ok(keeping) => self.core_mut().keeping = Some(keeping),
Err(error) => {
log::error!("recovery: off for this run: {error}");
self.core_mut().recovery_off = Some(error.to_string());
}
}
Self::keep_points_every(cx);
cx.on_app_quit(|host, _| {
if let Some(keeping) = host.core_mut().keeping.as_mut() {
keeping.end();
}
async {}
})
.detach();
}
/// Ticks every ten seconds for the window's life.
fn keep_points_every(cx: &mut Context<Self>) {
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
loop {
cx.background_executor().timer(Duration::from_secs(2)).await;
if this.update(cx, |host, cx| host.keep_points(cx)).is_err() {
break;
}
cx.background_executor().timer(Recovery::INTERVAL - Duration::from_secs(2)).await;
}
})
.detach();
}
/// Reopens what a crash left, then writes due points.
fn keep_points(&mut self, cx: &mut Context<Self>) {
// An idle engine reports no error when it ends.
crate::Stopping::check_engine(self, cx);
if let Some(error) = self.core_mut().recovery_off.take() {
self.toast_unlogged(text(cx, "office.recovery.off", &[("error", &error)]), Severity::Danger, cx);
}
if self.core().stopped.is_some() {
return;
}
if self.core().keeping.as_ref().is_some_and(|keeping| keeping.behind.is_some()) {
self.flush_index(cx);
}
if self.core().window.is_some() {
self.reopen_crashed(cx);
}
let app = Self::KIND.app;
let due: Vec<(u64, u64)> = self
.core()
.tabs
.iter()
.filter(|tab| tab.dirty(cx) && !tab.exporting && Self::Engine::settled(tab.view.read(cx)))
.map(|tab| (tab.id, Self::Engine::edited(tab.view.read(cx))))
.filter(|(id, edited)| {
let slot = self.core().keeping.as_ref().and_then(|keeping| keeping.slots.get(id));
slot.is_some_and(|slot| !slot.writing && slot.written != Some(*edited))
})
.collect();
for (id, edited) in due {
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let slot = keeping.slots.get_mut(&id).expect("a due tab has a slot");
slot.writing = true;
let kept = slot.kept.clone();
let folder = keeping.folder(&kept.slot);
let work = self.tab(id).expect("a due tab").work.join(format!("recovery.{}", kept.format));
self.read_in(
id,
move |worker, doc| {
Self::Engine::snapshot(worker, doc, &work, &kept.format)?;
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
let limits = ely_platform::storage::limits(app)?;
let recovery = match kept.sealed {
true => Recovery::sensitive(folder, RecoveryKey::load().key),
false => Recovery::new(folder),
};
let mut recovery = recovery.with_limits(limits.keep, limits.megabytes * 1024 * 1024);
recovery.write_once(&bytes, None, SystemTime::now()).map(|_| ())
},
move |host, written, _, cx| {
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
let Some(slot) = keeping.slots.get_mut(&id) else { return };
slot.writing = false;
match written {
Ok(()) => {
slot.written = Some(edited);
keeping.last = Some(SystemTime::now());
if keeping.behind.is_some() {
host.flush_index(cx);
}
}
Err(error) => {
log::error!("recovery: tab {id} point failed ({:?})", error.kind());
let shown = text(cx, "office.recovery.failed", &[("error", &error.to_string())]);
host.toast_unlogged(shown, Severity::Warning, cx);
}
}
},
cx,
);
}
}
/// Writes the index; a failure shows once and retries.
fn flush_index(&mut self, cx: &mut Context<Self>) {
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let error = match keeping.save_index() {
Ok(()) => return keeping.behind = None,
Err(error) => error.to_string(),
};
if keeping.behind.as_ref() == Some(&error) {
return;
}
log::error!("recovery: the index is not saved: {error}");
keeping.behind = Some(error.clone());
self.toast_unlogged(text(cx, "office.recovery.index", &[("error", &error)]), Severity::Danger, cx);
}
/// Each tab gets its own folder of points once open.
fn keep_tab(&mut self, id: u64, name: &str, sealed: bool, cx: &mut Context<Self>) {
let format = name
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.filter(|ext| Self::KIND.save_as.contains(&ext.as_str()) || Self::KIND.kept.contains(&ext.as_str()))
.unwrap_or_else(|| Self::KIND.save_as[0].to_string());
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let slot = format!("{}-{id}", std::process::id());
let kept = Kept { slot, name: name.to_string(), format, sealed, held: false };
keeping.slots.insert(id, Slot { kept, written: None, writing: false });
self.flush_index(cx);
}
/// A closed tab's points go: closing decided its fate.
fn forget_tab(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let Some(slot) = keeping.slots.remove(&id) else { return };
if let Err(error) = remove(&keeping.folder(&slot.kept.slot)) {
log::error!("recovery: tab {id} points stay: {error}");
}
self.flush_index(cx);
}
/// Opens each crashed slot's newest point as unsaved copy.
fn reopen_crashed(&mut self, cx: &mut Context<Self>) {
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let crashed = std::mem::take(&mut keeping.crashed);
if crashed.is_empty() {
return;
}
for kept in crashed {
let folder = self.core().keeping.as_ref().expect("keeping").folder(&kept.slot);
match restore(&kept, &folder, &self.core().work, self.core().next, false) {
Ok(Some((copy, at))) => {
log::info!("recovery: reopening a crashed {}", kept.format);
self.core_mut().keeping.as_mut().expect("keeping").reopening.push((copy.clone(), at, kept));
self.load(Source::Copy(copy), cx);
}
Ok(None) => {
if let Err(error) = remove(&folder) {
log::error!("recovery: an empty slot stays: {error}");
}
}
Err(error) => {
log::error!("recovery: a crashed copy cannot open ({:?})", error.kind());
let shown = text(cx, "office.recovery.lost", &[("name", &kept.name), ("error", &error.to_string())]);
self.toast_unlogged(shown, Severity::Danger, cx);
// Its points stay for a later start.
self.core_mut().keeping.as_mut().expect("keeping").held.push(kept);
}
}
}
self.flush_index(cx);
}
/// Opens `id`'s newest point as a copy; false if none.
fn reopen_point(&mut self, id: u64, cx: &mut Context<Self>) -> io::Result<bool> {
let (work, next) = (self.core().work.clone(), self.core().next);
let Some(keeping) = self.core_mut().keeping.as_mut() else { return Ok(false) };
// Out of the tab's hands: releasing it keeps these points.
let Some(slot) = keeping.slots.remove(&id) else { return Ok(false) };
let restored = restore(&slot.kept, &keeping.folder(&slot.kept.slot), &work, next, true);
match restored {
Ok(Some((copy, at))) => {
keeping.reopening.push((copy.clone(), at, slot.kept));
self.load(Source::Copy(copy), cx);
Ok(true)
}
Ok(None) => {
keeping.slots.insert(id, slot);
Ok(false)
}
Err(error) => {
keeping.held.push(slot.kept);
Err(error)
}
}
}
/// A reopened copy takes over its old points, unsaved.
fn reopened(&mut self, id: u64, path: &Path, cx: &mut Context<Self>) {
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
let Some(n) = keeping.reopening.iter().position(|(copy, ..)| copy == path) else {
return;
};
let (_, at, kept) = keeping.reopening.remove(n);
let name = kept.name.clone();
if let Some(slot) = keeping.slots.get_mut(&id) {
let sealed = slot.kept.sealed || kept.sealed;
slot.kept = Kept { sealed, held: false, ..kept };
}
// The point it took over is the latest kept.
keeping.last = Some(keeping.last.map_or(at, |last| last.max(at)));
self.flush_index(cx);
let Some(tab) = self.tab_mut(id) else { return };
tab.session.edit();
let shown = text(cx, "office.recovery.back", &[("name", &name), ("at", &clock(at))]);
self.toast_unlogged(shown, Severity::Warning, cx);
}
}
impl<H: Host> Recovering for H {}
/// The newest point copied to the work folder, and when.
fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result<Option<(PathBuf, SystemTime)>> {
let recovery = match kept.sealed {
true => {
let key = RecoveryKey::load();
// This run's points open with this run's key.
if !key.lasting && !this_run {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store"));
}
Recovery::sensitive(folder.to_path_buf(), key.key)
}
false => Recovery::new(folder.to_path_buf()),
};
let Some(point) = recovery.points()?.into_iter().next() else {
return Ok(None);
};
let dir = crate::files::work_dir(work, next)?;
let copy = dir.join(&kept.name).with_extension(&kept.format);
recovery.restore(&point, &copy)?;
Ok(Some((copy, point.at)))
}
/// A recovery time as this machine's clock reads it.
pub(crate) fn clock(at: SystemTime) -> String {
match jiff::Timestamp::try_from(at) {
Ok(at) => at.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m-%d %H:%M").to_string(),
Err(error) => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{Keeping, Kept};
fn kept(slot: &str, held: bool) -> Kept {
Kept { slot: slot.into(), name: "a.docx".into(), format: "docx".into(), sealed: false, held }
}
#[cfg(unix)]
#[test]
fn a_failed_index_write_keeps_the_last_one() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let mut keeping = Keeping::start_in(dir.path().to_path_buf()).expect("starts");
keeping.crashed.push(kept("1-0", false));
keeping.save_index().expect("the first index");
let recovery = dir.path().join("recovery");
std::fs::set_permissions(&recovery, std::fs::Permissions::from_mode(0o555)).expect("locks");
keeping.crashed.push(kept("1-1", false));
let failed = keeping.save_index();
std::fs::set_permissions(&recovery, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
assert!(failed.is_err(), "the folder refused the write");
let read = super::read_index(dir.path()).expect("the old index still reads");
assert_eq!(read.len(), 1);
}
#[test]
fn a_clean_end_keeps_only_what_never_reopened() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut keeping = Keeping::start_in(dir.path().to_path_buf()).expect("starts");
for slot in ["1-0", "1-1"] {
std::fs::create_dir_all(keeping.folder(slot).join("points")).expect("a slot");
}
keeping.crashed.push(kept("1-0", false));
keeping.held.push(kept("1-1", false));
keeping.save_index().expect("the index");
keeping.end();
let again = Keeping::start_in(dir.path().to_path_buf()).expect("starts again");
let slots: Vec<&str> = again.crashed.iter().map(|kept| kept.slot.as_str()).collect();
assert_eq!(slots, ["1-1"], "only the held slot comes back");
assert!(again.folder("1-1").exists() && !again.folder("1-0").exists());
}
}
+465
View File
@@ -0,0 +1,465 @@
//! 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_cancelled(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_cancelled(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)
})
}
+127
View File
@@ -0,0 +1,127 @@
//! The engine ended: say so, then reopen (§6.3).
use std::{path::PathBuf, sync::Arc};
use ely_gpui_component::{feedback::Banner, primitives::Severity};
use gpui::{AppContext as _, Context};
use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, Tabs as _, recover::clock, text};
/// Why the engine ended, and whether it is coming back.
pub struct Stopped {
why: String,
restarting: bool,
}
pub trait Stopping: Host {
/// After an engine error: asks if it ended.
fn check_engine(&mut self, cx: &mut Context<Self>) {
if self.core().stopped.is_some() {
return;
}
let worker = Arc::clone(&self.core().worker);
cx.spawn(async move |this, cx| {
let ended = cx
.background_spawn(async move { Self::Engine::ended(&mut worker.lock().expect("the worker lock")) })
.await;
let Some(why) = ended else { return };
let noted = this.update(cx, |host, cx| {
if host.core().stopped.is_none() {
log::error!("{}: the engine ended ({why})", Self::KIND.app.slug());
host.core_mut().stopped = Some(Stopped { why, restarting: false });
cx.notify();
}
});
if noted.is_err() {
log::info!("office: the engine ended after its window");
}
})
.detach();
}
/// What ended, the last point, and the way back.
fn stopped_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
let stopped = self.core().stopped.as_ref()?;
let at = match self.core().keeping.as_ref().and_then(|keeping| keeping.last) {
Some(at) => clock(at),
None => text(cx, "office.stopped.never", &[]).to_string(),
};
let said = text(cx, "office.stopped", &[("why", &stopped.why), ("at", &at)]);
let banner = Banner::new("office-stopped", Severity::Danger, said);
if stopped.restarting {
return Some(banner);
}
let this = cx.entity();
Some(banner.action(text(cx, "office.stopped.reopen", &[]), move |_, cx| {
this.update(cx, |host, cx| host.restart_engine(cx))
}))
}
/// A new worker, then each tab from point or file.
fn restart_engine(&mut self, cx: &mut Context<Self>) {
let Some(stopped) = self.core_mut().stopped.as_mut() else { return };
stopped.restarting = true;
cx.notify();
let worker = Arc::clone(&self.core().worker);
cx.spawn(async move |this, cx| {
let restarted = cx
.background_spawn(async move {
Self::Engine::restart(Self::KIND.app, &mut worker.lock().expect("the worker lock"))
})
.await;
let reopened = this.update(cx, |host, cx| match restarted {
Ok(()) => host.reopen_all(cx),
Err(error) => {
log::error!("{}: the engine does not restart: {error}", Self::KIND.app.slug());
host.core_mut().stopped = Some(Stopped { why: error.to_string(), restarting: false });
cx.notify();
}
});
if reopened.is_err() {
log::info!("office: the engine restarted after its window");
}
})
.detach();
}
/// Unsaved tabs from their newest point, clean ones from file.
fn reopen_all(&mut self, cx: &mut Context<Self>) {
self.core_mut().stopped = None;
let tabs: Vec<(u64, String, Option<PathBuf>, bool)> = self
.core()
.tabs
.iter()
.map(|tab| (tab.id, tab.name.to_string(), tab.session.path().map(PathBuf::from), tab.dirty(cx)))
.collect();
let mut lost = Vec::new();
for (id, name, path, dirty) in tabs {
if !dirty {
self.release_tab(id, cx);
if let Some(path) = path {
self.open_file(path, cx);
}
continue;
}
match self.reopen_point(id, cx) {
Ok(true) => {}
Ok(false) => lost.push(name),
Err(error) => {
log::error!("office: tab {id} point does not open ({:?})", error.kind());
lost.push(format!("{name} ({error})"));
}
}
self.release_tab(id, cx);
}
// Questions about the released tabs have no answer now.
if matches!(self.core().dialog, Some(Dialog::Unsaved { .. } | Dialog::Conflict { .. })) {
self.core_mut().dialog = None;
}
log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len());
if !lost.is_empty() {
let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]);
self.toast_unlogged(shown, Severity::Danger, cx);
}
}
}
impl<H: Host> Stopping for H {}
+66
View File
@@ -0,0 +1,66 @@
//! Zooming a document: keys, View menu items, the status control.
use ely_gpui_component::shell::{ZoomControl, ZoomStep};
use ely_ui::commands;
use gpui::{App, Context, Div, InteractiveElement as _, KeyBinding, MenuItem, actions};
use crate::{Host, Tabs as _, lok::Lok, text};
actions!(office, [ZoomIn, ZoomOut, ActualSize]);
/// Command with =, - and 0, as Mac apps do.
pub fn bind_zoom_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("secondary-=", ZoomIn, Some(commands::CONTEXT)),
KeyBinding::new("secondary--", ZoomOut, Some(commands::CONTEXT)),
KeyBinding::new("secondary-0", ActualSize, Some(commands::CONTEXT)),
]);
}
/// Zoom In, Zoom Out, and Actual Size (or Fit Slide).
pub fn zoom_items(fits: bool, cx: &App) -> [MenuItem; 3] {
let actual = if fits { "office.zoom.fit" } else { "office.zoom.actual" };
[
MenuItem::action(text(cx, "office.zoom.in", &[]), ZoomIn),
MenuItem::action(text(cx, "office.zoom.out", &[]), ZoomOut),
MenuItem::action(text(cx, actual, &[]), ActualSize),
]
}
/// The active document's zoom, by key, menu or status control.
pub trait Zooming: Host<Engine = Lok> {
/// Actual size fits a whole page instead (ElyShow).
const FITS: bool = false;
fn zoom(&mut self, step: ZoomStep, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
return;
};
view.update(cx, |view, cx| match step {
ZoomStep::In => view.zoom_step(true, cx),
ZoomStep::Out => view.zoom_step(false, cx),
ZoomStep::Reset if Self::FITS => view.fit_page(cx),
ZoomStep::Reset => view.set_zoom(1.0, cx),
});
cx.notify();
}
/// The window root, answering the zoom actions.
fn bind_zoom(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|host: &mut Self, _: &ZoomIn, _, cx| host.zoom(ZoomStep::In, cx)))
.on_action(cx.listener(|host: &mut Self, _: &ZoomOut, _, cx| host.zoom(ZoomStep::Out, cx)))
.on_action(cx.listener(|host: &mut Self, _: &ActualSize, _, cx| host.zoom(ZoomStep::Reset, cx)))
}
/// Minus, the percent, plus; none without a document.
fn zoom_control(&self, cx: &mut Context<Self>) -> Option<ZoomControl> {
let view = self.active_tab()?.view.clone();
let percent = (view.read(cx).zoom() * 100.0).round() as u32;
let this = cx.entity().downgrade();
Some(ZoomControl::new("office-zoom").owned(percent, move |step, _, cx| {
if let Err(error) = this.update(cx, |host, cx| host.zoom(step, cx)) {
log::warn!("office: zoom without a window: {error}");
}
}))
}
}