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:
817 files changed
+143101
No files matched your search
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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, ©, format)?;
|
||||
let bytes = std::fs::read(©)?;
|
||||
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, ¬es).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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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, ©)?;
|
||||
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(©, 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 {}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, ©)?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}");
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user