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

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

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
//! Saving ordinary files on their own, kept per app (§6.2).
use std::io;
use ely_platform::{AppId, settings::Store};
use gpui::{App, AsyncApp, Global};
use serde_json::Value;
const KEY: &str = "autosave";
/// Whether autosave is on, and the latest switch.
struct Autosave {
store: Option<Store>,
on: bool,
turns: u64,
}
impl Global for Autosave {}
/// Reads `app`'s choice; a bad entry is reported and off.
pub(super) fn install(app: AppId, cx: &mut App) {
let store = Store::app(app);
let read = store
.as_ref()
.map_err(|error| io::Error::new(error.kind(), error.to_string()))
.and_then(|store| parse(store.read()?.get(KEY)));
let on = read.unwrap_or_else(|error| {
log::error!("settings: autosave unreadable: {error}");
cx.global_mut::<super::Current>().app_error.insert(super::AUTOSAVE, error.to_string().into());
false
});
cx.set_global(Autosave { store: store.ok(), on, turns: 0 });
}
/// Absent is off; else a boolean.
fn parse(saved: Option<&Value>) -> io::Result<bool> {
match saved {
None => Ok(false),
Some(Value::Bool(on)) => Ok(*on),
Some(other) => Err(io::Error::new(io::ErrorKind::InvalidData, format!("settings: autosave cannot be {other}"))),
}
}
/// Off without reading the file: for tests.
#[cfg(any(test, feature = "test-support"))]
pub(super) fn install_defaults(cx: &mut App) {
cx.set_global(Autosave { store: None, on: false, turns: 0 });
}
/// Whether ordinary files save on their own.
pub fn on(cx: &App) -> bool {
cx.global::<Autosave>().on
}
/// Takes the switch now; the latest one is written.
pub(super) fn set(on: bool, cx: &mut App) {
let autosave = cx.global_mut::<Autosave>();
autosave.on = on;
autosave.turns += 1;
let turn = autosave.turns;
let Some(store) = autosave.store.clone() else {
return super::unsaved(super::AUTOSAVE, Some("no settings file for this app".into()), cx);
};
cx.spawn(async move |cx: &mut AsyncApp| {
let latest = cx.update(|cx| cx.global::<Autosave>().turns == turn);
if !latest {
return;
}
let written = cx.background_executor().spawn(async move { store.set(KEY, Value::Bool(on)) }).await;
cx.update(|cx| {
super::unsaved(super::AUTOSAVE, written.err().map(|error| {
log::error!("settings: autosave not saved: {error}");
error.to_string().into()
}), cx)
});
})
.detach();
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn the_switch_reads_strictly() {
assert!(!super::parse(None).unwrap());
assert!(super::parse(Some(&json!(true))).unwrap());
for bad in [json!("yes"), json!(1), json!(null)] {
assert!(super::parse(Some(&bad)).is_err(), "{bad}");
}
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Diagnostics export: what goes in is shown first (§18.2).
use ely_gpui_component::{
buttons::Button,
feedback::Banner,
forms::Switch,
primitives::Severity,
settings::{SettingsRow, SettingsSection},
theme::{ActiveTheme as _, TextSize},
};
use ely_platform::diagnostics::{self, Part, Redact};
use gpui::{
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Role, SharedString,
StatefulInteractiveElement as _, Styled as _,
WeakEntity, div,
};
use super::window::SettingsView;
use crate::i18n::text;
/// What the bundle holds; how the last export went.
#[derive(Default)]
pub(crate) struct Diagnostics {
parts: Option<Result<Vec<Part>, SharedString>>,
/// Paths and names go out only when asked.
unredacted: bool,
status: Option<(Severity, SharedString)>,
}
impl SettingsView {
/// Lists the parts off the window thread.
pub(super) fn read_diagnostics(&mut self, cx: &mut Context<Self>) {
let app = self.app;
let read = cx.background_executor().spawn(async move { diagnostics::gather(app) });
cx.spawn(async move |this, cx| {
let parts = read.await.map_err(|error| SharedString::from(error.to_string()));
this.update(cx, |this, cx| {
this.diagnostics.parts = Some(parts);
cx.notify();
})
})
.detach();
}
pub(super) fn diagnostics_page(&self, this: WeakEntity<Self>, cx: &App) -> AnyElement {
let found = match &self.diagnostics.parts {
None => Vec::new(),
Some(Ok(parts)) if parts.is_empty() => vec![text(cx, "settings.diagnostics.none", &[])],
Some(Ok(parts)) => parts.iter().map(|part| line(part, cx)).collect(),
Some(Err(error)) => vec![text(cx, "settings.diagnostics.failed", &[("error", error)])],
};
let some = matches!(&self.diagnostics.parts, Some(Ok(parts)) if !parts.is_empty());
let (toggle, export) = (this.clone(), this);
let mut section = SettingsSection::new(text(cx, "settings.diagnostics", &[]))
.row(
SettingsRow::new(text(cx, "settings.diagnostics.included", &[]))
.control(
div()
.id("settings-parts")
.role(Role::List)
.aria_label(text(cx, "settings.diagnostics.included", &[]))
.flex()
.flex_col()
.text_size(cx.theme().text_size(TextSize::Sm))
.children(found.into_iter().enumerate().map(|(at, said)| {
div().id(("settings-part", at)).role(Role::ListItem).aria_label(said.clone()).child(said)
})),
),
)
.row(
SettingsRow::new(text(cx, "settings.diagnostics.redact", &[])).control(
Switch::new("settings-redact", !self.diagnostics.unredacted)
.name(text(cx, "settings.diagnostics.redact", &[]))
.on_change(move |on, _, cx| {
toggle
.update(cx, |this, cx| {
this.diagnostics.unredacted = !on;
cx.notify();
})
.ok();
}),
),
)
.row(
SettingsRow::new(text(cx, "settings.diagnostics.export", &[])).control(
Button::new("settings-export", text(cx, "settings.diagnostics.export", &[]))
.disabled(!some)
.on_click(move |_, _, cx| {
export.update(cx, |this, cx| this.export_diagnostics(cx)).ok();
}),
),
);
if let Some((severity, said)) = self.diagnostics.status.clone() {
section = section.row(Banner::new("settings-export-status", severity, said));
}
section.into_any_element()
}
/// Asks where, then writes the bundle in the background.
fn export_diagnostics(&mut self, cx: &mut Context<Self>) {
let Some(Ok(parts)) = self.diagnostics.parts.clone() else {
return;
};
let (app, redact) = (self.app, !self.diagnostics.unredacted);
let folder = directories::UserDirs::new()
.and_then(|dirs| dirs.document_dir().map(std::path::Path::to_path_buf))
.unwrap_or_else(std::env::temp_dir);
let name = format!("{}-diagnostics.txt", app.name());
let chosen = cx.prompt_for_new_path(&folder, Some(&name));
cx.spawn(async move |this, cx| {
let path = match chosen.await {
Ok(Ok(Some(path))) => path,
Ok(Ok(None)) => return log::info!("settings: diagnostics export cancelled"),
Ok(Err(error)) => return log::error!("settings: the save panel failed: {error}"),
Err(error) => return log::error!("settings: the save panel failed: {error}"),
};
let target = path.clone();
let written = cx
.background_executor()
.spawn(async move {
// Names that cannot be read stop the export.
let redact = redact.then(Redact::of_account).transpose()?;
let bundle = diagnostics::bundle(app, &parts, redact.as_ref())?;
std::fs::write(&target, bundle)
})
.await;
this.update(cx, |this, cx| {
let shown = path.display().to_string();
this.diagnostics.status = Some(match written {
Ok(()) => {
log::info!("settings: diagnostics exported, redacted {redact}");
(Severity::Success, text(cx, "settings.diagnostics.saved", &[("path", &shown)]))
}
Err(error) => (Severity::Danger, text(cx, "settings.diagnostics.failed", &[("error", &error.to_string())])),
});
cx.notify();
})
.ok();
})
.detach();
}
}
/// `ely.log · 120 KB` or a crash report's file name.
fn line(part: &Part, cx: &App) -> SharedString {
let file = part.path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
let name = match file.as_str() {
"ely.log" => text(cx, "settings.diagnostics.log", &[]).to_string(),
_ => text(cx, "settings.diagnostics.crash", &[("name", &file)]).to_string(),
};
let size = format!("{} KB", part.bytes.div_ceil(1024));
text(cx, "settings.diagnostics.size", &[("name", &name), ("size", &size)])
}
+144
View File
@@ -0,0 +1,144 @@
//! Each app's main window opens where it was left (§18.1).
use std::time::Duration;
use ely_platform::{AppId, settings::Store};
use gpui::{App, AppContext as _, Bounds, Context, Entity, Global, Pixels, Window, WindowBounds, point, px, size};
use serde_json::{Value, json};
const KEY: &str = "window";
const SETTLE: Duration = Duration::from_millis(500);
/// Where the window was last, if that place still shows.
pub fn restore(app: AppId, cx: &mut App) -> Option<WindowBounds> {
let unreadable = |why: String, cx: &mut App| {
log::error!("settings: the window place is unreadable: {why}");
cx.global_mut::<super::Current>().app_error.insert(super::WINDOW, why.into());
None
};
let saved = match Store::app(app).and_then(|store| store.read()) {
Ok(fields) => fields.get(KEY).cloned()?,
Err(error) => return unreadable(error.to_string(), cx),
};
let Some(bounds) = parse(&saved) else {
return unreadable(format!("the window place cannot be {saved}"), cx);
};
let center = bounds.get_bounds().center();
// A display since unplugged would hide the window.
cx.displays().iter().any(|display| display.bounds().contains(&center)).then_some(bounds)
}
fn parse(saved: &Value) -> Option<WindowBounds> {
let number = |key: &str| saved.get(key)?.as_f64().map(|n| px(n as f32));
let bounds = Bounds::new(point(number("x")?, number("y")?), size(number("width")?, number("height")?));
match saved.get("mode")?.as_str()? {
"windowed" => Some(WindowBounds::Windowed(bounds)),
"maximized" => Some(WindowBounds::Maximized(bounds)),
"fullscreen" => Some(WindowBounds::Fullscreen(bounds)),
_ => None,
}
}
fn written(bounds: WindowBounds) -> Value {
let (mode, frame): (&str, Bounds<Pixels>) = match bounds {
WindowBounds::Windowed(frame) => ("windowed", frame),
WindowBounds::Maximized(frame) => ("maximized", frame),
WindowBounds::Fullscreen(frame) => ("fullscreen", frame),
};
let n = |pixels: Pixels| f64::from(f32::from(pixels));
json!({
"mode": mode,
"x": n(frame.origin.x),
"y": n(frame.origin.y),
"width": n(frame.size.width),
"height": n(frame.size.height),
})
}
struct Frame {
app: AppId,
moves: u64,
saved: u64,
last: Option<WindowBounds>,
}
impl Frame {
/// Records a write; failures show and retry later.
fn settled(&mut self, moves: u64, written: std::io::Result<()>, cx: &mut App) {
match written {
Ok(()) => {
self.saved = moves;
super::unsaved(super::WINDOW, None, cx);
}
Err(error) => {
log::error!("settings: the window place is not saved: {error}");
super::unsaved(super::WINDOW, Some(error.to_string().into()), cx);
}
}
}
/// Writes a pending place now; quitting cannot wait.
fn flush(&mut self) {
let Some(bounds) = self.last.filter(|_| self.moves != self.saved) else {
return;
};
match Store::app(self.app).and_then(|store| store.set(KEY, written(bounds))) {
Ok(()) => self.saved = self.moves,
Err(error) => log::error!("settings: the window place is not saved at quit: {error}"),
}
}
}
/// The followed windows stay alive with the app.
struct Frames(Vec<Entity<Frame>>);
impl Global for Frames {}
/// Saves `window`'s place shortly after it stops changing.
pub fn follow(app: AppId, window: &mut Window, cx: &mut App) {
let frame = cx.new(|cx: &mut Context<Frame>| {
// Quitting right after a move still keeps the place.
cx.on_app_quit(|frame, _| {
frame.flush();
async {}
})
.detach();
cx.observe_window_bounds(window, |frame, window, cx| {
frame.moves += 1;
frame.last = Some(window.window_bounds());
let (moves, app, bounds) = (frame.moves, frame.app, window.window_bounds());
cx.spawn(async move |frame, cx| {
cx.background_executor().timer(SETTLE).await;
if frame.read_with(cx, |frame, _| frame.moves != moves).unwrap_or(true) {
return;
}
let saved = cx
.background_executor()
.spawn(async move { Store::app(app)?.set(KEY, written(bounds)) })
.await;
frame.update(cx, |frame, cx| frame.settled(moves, saved, cx)).ok();
})
.detach();
})
.detach();
Frame { app, moves: 0, saved: 0, last: None }
});
if !cx.has_global::<Frames>() {
cx.set_global(Frames(Vec::new()));
}
cx.global_mut::<Frames>().0.push(frame);
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, WindowBounds, point, px, size};
#[test]
fn a_place_reads_back_as_written() {
let frame = Bounds::new(point(px(40.0), px(60.0)), size(px(900.0), px(600.0)));
for bounds in [WindowBounds::Windowed(frame), WindowBounds::Maximized(frame), WindowBounds::Fullscreen(frame)] {
assert_eq!(super::parse(&super::written(bounds)), Some(bounds));
}
assert_eq!(super::parse(&serde_json::json!({"mode": "tiled", "x": 0})), None);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! The shared settings: read at start, followed while running (§18.1).
use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use ely_platform::settings::{Shared, Store};
use futures::{StreamExt, channel::mpsc};
use gpui::{App, AsyncApp, Global, SharedString};
use notify::{RecursiveMode, Watcher as _};
use serde_json::Value;
pub mod autosave;
mod diagnostics;
mod frame;
mod storage;
pub(crate) mod panels;
#[cfg(test)]
mod tests;
mod window;
pub use frame::{follow as follow_frame, restore as restored_frame};
pub use window::open;
const SETTLE: Duration = Duration::from_millis(150);
/// The settings in force, why reading failed, why saving failed.
pub struct Current {
pub shared: Shared,
pub error: Option<SharedString>,
/// Each save kind's last failure, cleared by its success.
pub unsaved: BTreeMap<&'static str, SharedString>,
/// Why each kind of app setting was unreadable.
pub app_error: BTreeMap<&'static str, SharedString>,
}
impl Global for Current {}
/// The settings every app follows.
pub fn current(cx: &App) -> &Shared {
&cx.global::<Current>().shared
}
/// Defaults in force without reading the file, for tests.
#[cfg(any(test, feature = "test-support"))]
pub fn install_defaults(cx: &mut App) {
cx.set_global(Current { shared: Shared::default(), error: None, unsaved: BTreeMap::new(), app_error: BTreeMap::new() });
panels::install_defaults(cx);
autosave::install_defaults(cx);
}
/// Reads the shared file and follows later writes to it.
pub fn install(app: ely_platform::AppId, cx: &mut App) {
let (shared, error) = read();
cx.set_global(Current { shared, error, unsaved: BTreeMap::new(), app_error: BTreeMap::new() });
panels::install(app, cx);
autosave::install(app, cx);
if let Err(error) = follow(cx) {
log::error!("settings: changes from other apps are not followed: {error}");
}
}
/// The file's settings; defaults with the reason when unreadable.
fn read() -> (Shared, Option<SharedString>) {
match Shared::load() {
Ok(shared) => (shared, None),
Err(error) => {
log::error!("settings: unreadable, defaults in force: {error}");
(Shared::default(), Some(error.to_string().into()))
}
}
}
fn follow(cx: &mut App) -> std::io::Result<()> {
let path = Store::shared()?.path().to_path_buf();
let dir = path.parent().expect("the settings folder").to_path_buf();
std::fs::create_dir_all(&dir)?;
let (sender, mut changes) = mpsc::unbounded();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
Ok(event) if event.paths.iter().any(|changed| changed.file_name() == path.file_name()) => {
let _ = sender.unbounded_send(());
}
Ok(_) => {}
Err(error) => log::warn!("settings: watching failed: {error}"),
})
.map_err(std::io::Error::other)?;
watcher.watch(&dir, RecursiveMode::NonRecursive).map_err(std::io::Error::other)?;
cx.spawn(async move |cx: &mut AsyncApp| {
let _watcher = watcher;
while changes.next().await.is_some() {
cx.background_executor().timer(SETTLE).await;
while changes.try_recv().is_ok() {}
let read = cx.background_executor().spawn(async { read() }).await;
cx.update(|cx| changed(read, cx));
}
})
.detach();
Ok(())
}
fn changed((shared, error): (Shared, Option<SharedString>), cx: &mut App) {
let current = cx.global::<Current>();
if current.shared == shared && current.error == error {
return;
}
log::info!("settings: changed on disk");
let (unsaved, app_error) = (current.unsaved.clone(), current.app_error.clone());
cx.set_global(Current { shared, error, unsaved, app_error });
crate::theme::settings_changed(cx);
cx.refresh_windows();
}
/// Save kinds, each with its own failure.
pub(crate) const SHARED: &str = "shared";
pub(crate) const PANELS: &str = "panels";
pub(crate) const WINDOW: &str = "window";
pub(crate) const AUTOSAVE: &str = "autosave";
/// Records and shows how `kind`'s last save went.
pub(crate) fn unsaved(kind: &'static str, failed: Option<SharedString>, cx: &mut App) {
let current = cx.global_mut::<Current>();
match failed {
Some(error) => {
current.unsaved.insert(kind, error);
}
// A good write also mends what could not be read.
None => {
current.unsaved.remove(kind);
current.app_error.remove(kind);
}
}
cx.refresh_windows();
}
/// Writes one shared field in the background; others stay.
pub fn set(key: &'static str, value: Value, cx: &mut App) {
saved(key, value, None, cx).detach();
}
/// A field's latest commit number, and this commit's.
pub(crate) type Turn = (Arc<AtomicU64>, u64);
/// As `set`; whether written, a later turn skipping it.
pub(crate) fn saved(key: &'static str, value: Value, turn: Option<Turn>, cx: &mut App) -> gpui::Task<bool> {
let write = cx.background_executor().spawn(async move {
let still = || turn.is_none_or(|(latest, asked)| latest.load(Ordering::SeqCst) == asked);
Store::shared()?.set_if(key, value, still)
});
cx.spawn(async move |cx: &mut AsyncApp| {
let written = write.await;
let read = cx.background_executor().spawn(async { read() }).await;
cx.update(|cx| {
changed(read, cx);
let ok = matches!(written, Ok(true));
// The reread must not hide this write's failure.
unsaved(SHARED, written.err().map(|error| {
log::error!("settings: {key} not saved: {error}");
error.to_string().into()
}), cx);
ok
})
})
}
+135
View File
@@ -0,0 +1,135 @@
//! Side panel widths, kept per app (§18.1).
use std::{io, time::Duration};
use ely_platform::{AppId, settings::Store};
use gpui::{App, AsyncApp, Global, Pixels, px};
use serde_json::{Value, json};
const KEY: &str = "panels";
const SETTLE: Duration = Duration::from_millis(500);
/// Widths the user dragged to; none keeps the default.
pub(crate) struct Panels {
store: Option<Store>,
left: Option<Pixels>,
right: Option<Pixels>,
moves: u64,
saved: u64,
}
impl Global for Panels {}
/// Reads `app`'s widths; a bad entry is reported, not guessed.
pub(super) fn install(app: AppId, cx: &mut App) {
let store = Store::app(app);
let read = store.as_ref().map_err(|error| io::Error::new(error.kind(), error.to_string())).and_then(|store| {
let fields = store.read()?;
parse(fields.get(KEY))
});
let (left, right) = read.unwrap_or_else(|error| {
log::error!("settings: panel widths unreadable: {error}");
cx.global_mut::<super::Current>().app_error.insert(super::PANELS, error.to_string().into());
(None, None)
});
cx.set_global(Panels { store: store.ok(), left, right, moves: 0, saved: 0 });
// Quitting right after a drag still keeps the width.
cx.on_app_quit(|cx| {
flush(cx);
async {}
})
.detach();
}
/// Absent or null is default; else a positive width.
fn parse(saved: Option<&Value>) -> io::Result<(Option<Pixels>, Option<Pixels>)> {
let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidData, format!("settings: panels {why}"));
let fields = match saved {
None | Some(Value::Null) => return Ok((None, None)),
Some(Value::Object(fields)) => fields,
Some(other) => return Err(invalid(format!("cannot be {other}"))),
};
let width = |side: &str| match fields.get(side) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) if n.as_f64().is_some_and(|n| n.is_finite() && n > 0.0) => {
Ok(n.as_f64().map(|n| px(n as f32)))
}
Some(other) => Err(invalid(format!("{side} cannot be {other}"))),
};
Ok((width("left")?, width("right")?))
}
/// Defaults in force, nothing kept: for tests.
#[cfg(any(test, feature = "test-support"))]
pub(super) fn install_defaults(cx: &mut App) {
cx.set_global(Panels { store: None, left: None, right: None, moves: 0, saved: 0 });
}
/// The user's width for the left or right panel.
pub(crate) fn width(left: bool, cx: &App) -> Option<Pixels> {
let panels = cx.global::<Panels>();
if left { panels.left } else { panels.right }
}
/// Takes a dragged width; saved once dragging rests.
pub(crate) fn set_width(left: bool, width: Pixels, cx: &mut App) {
let panels = cx.global_mut::<Panels>();
*(if left { &mut panels.left } else { &mut panels.right }) = Some(width);
panels.moves += 1;
let moves = panels.moves;
cx.spawn(async move |cx: &mut AsyncApp| {
cx.background_executor().timer(SETTLE).await;
let Some((store, saved)) = cx.update(|cx| pending(cx).filter(|_| cx.global::<Panels>().moves == moves)) else {
return;
};
let written = cx.background_executor().spawn(async move { store.set(KEY, saved) }).await;
cx.update(|cx| settled(moves, written, cx));
})
.detach();
}
/// What to write while the last drag is unkept.
fn pending(cx: &App) -> Option<(Store, Value)> {
let panels = cx.global::<Panels>();
let store = panels.store.clone().filter(|_| panels.moves != panels.saved)?;
let n = |w: Option<Pixels>| w.map(|w| f64::from(f32::from(w)));
Some((store, json!({ "left": n(panels.left), "right": n(panels.right) })))
}
/// Records a write; failures show and retry later.
fn settled(moves: u64, written: io::Result<()>, cx: &mut App) {
match written {
Ok(()) => {
cx.global_mut::<Panels>().saved = moves;
super::unsaved(super::PANELS, None, cx);
}
Err(error) => {
log::error!("settings: panel widths not saved: {error}");
super::unsaved(super::PANELS, Some(error.to_string().into()), cx);
}
}
}
/// Writes a pending width now; quitting cannot wait.
fn flush(cx: &mut App) {
if let Some((store, saved)) = pending(cx) {
let moves = cx.global::<Panels>().moves;
let written = store.set(KEY, saved);
settled(moves, written, cx);
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use serde_json::json;
#[test]
fn widths_read_strictly() {
assert_eq!(super::parse(None).unwrap(), (None, None));
assert_eq!(super::parse(Some(&json!({"left": 300, "right": null}))).unwrap(), (Some(px(300.0)), None));
for bad in [json!({"left": "broken", "right": 320}), json!({"left": -4}), json!([1])] {
assert!(super::parse(Some(&bad)).is_err(), "{bad}");
}
}
}
+236
View File
@@ -0,0 +1,236 @@
//! What this app keeps, clearing it, and recovery limits (§18.3).
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use ely_gpui_component::{
buttons::{Button, SegmentedControl},
feedback::Banner,
overlays::ConfirmDialog,
primitives::Severity,
settings::{SettingsRow, SettingsSection},
};
use ely_platform::storage::{self, Kind, Limits};
use gpui::{AnyElement, App, Context, IntoElement, SharedString, WeakEntity};
use super::window::SettingsView;
use crate::i18n::text;
/// Last read sizes and limits; a clear waiting or failed.
#[derive(Default)]
pub(crate) struct Storage {
usage: Option<Result<Vec<(Kind, u64, bool)>, SharedString>>,
limits: Option<Result<Limits, SharedString>>,
/// A clear waiting for the user's yes.
confirming: Option<Kind>,
failed: Option<SharedString>,
}
/// Limit writes outlive the settings window that asked.
#[derive(Default)]
struct Limiting {
/// The latest commit; older writes are dropped.
asked: Arc<AtomicU64>,
/// Picked but not yet written; a read keeps them.
pending: Option<Limits>,
/// The last write's failure, until one succeeds.
failed: Option<SharedString>,
}
impl gpui::Global for Limiting {}
impl SettingsView {
/// Reads sizes and limits off the window thread.
pub(super) fn read_storage(&mut self, cx: &mut Context<Self>) {
let app = self.app;
let read = cx.background_executor().spawn(async move {
let usage = storage::usage(app).and_then(|usage| {
usage.into_iter().map(|(kind, bytes)| Ok((kind, bytes, storage::clearing(app, kind)?))).collect()
});
(usage, storage::limits(app))
});
cx.spawn(async move |this, cx| {
let (usage, limits) = read.await;
this.update(cx, |this, cx| {
this.storage.usage = Some(usage.map_err(|error| error.to_string().into()));
this.storage.limits = Some(match cx.default_global::<Limiting>().pending {
Some(wanted) => Ok(wanted),
None => limits.map_err(|error| error.to_string().into()),
});
cx.notify();
})
})
.detach();
}
pub(super) fn storage_page(&self, this: WeakEntity<Self>, cx: &App) -> AnyElement {
let mut section = SettingsSection::new(text(cx, "settings.storage", &[]));
match &self.storage.usage {
None => {}
Some(Err(error)) => section = section.row(Banner::new("storage-unread", Severity::Danger, error.clone())),
Some(Ok(usage)) => {
// Only ElyMark keeps web content.
let shown = usage.iter().filter(|(kind, ..)| *kind != Kind::Cache || self.app == ely_platform::AppId::Mark);
for (kind, bytes, clearing) in shown {
section = section.row(row(*kind, *bytes, *clearing, this.clone(), cx));
}
}
}
match &self.storage.limits {
None => {}
Some(Err(error)) => section = section.row(Banner::new("storage-limits", Severity::Danger, error.clone())),
Some(Ok(limits)) => {
let keep = choices("storage-keep", &[10, 20, 50], limits.keep as u64, this.clone(), |limits, n| Limits {
keep: n as usize,
..limits
});
let space = choices("storage-space", &[32, 64, 256], limits.megabytes, this, |limits, n| Limits {
megabytes: n,
..limits
});
section = section
.row(SettingsRow::new(text(cx, "settings.storage.keep", &[])).control(keep))
.row(SettingsRow::new(text(cx, "settings.storage.space", &[])).control(space));
}
}
let unsaved = cx.try_global::<Limiting>().and_then(|limiting| limiting.failed.clone());
if let Some(failed) = unsaved {
section = section.row(Banner::new("storage-unsaved", Severity::Danger, failed));
}
if let Some(failed) = self.storage.failed.clone() {
section = section.row(Banner::new("storage-failed", Severity::Danger, failed));
}
section.into_any_element()
}
/// The yes-or-no before data is deleted (§4.2).
pub(super) fn storage_dialog(&self, this: WeakEntity<Self>, cx: &App) -> Option<AnyElement> {
let kind = self.storage.confirming?;
let what = name(kind, cx);
let message = match kind.at_next_start() {
true => text(cx, "settings.storage.ask_later", &[("what", &what)]),
false => text(cx, "settings.storage.ask_now", &[("what", &what)]),
};
let close = this.clone();
let dialog = ConfirmDialog::new("storage-ask", text(cx, "settings.storage.ask_title", &[("what", &what)]), message, move |_, cx| {
close.update(cx, |this, cx| {
this.storage.confirming = None;
cx.notify();
})
.ok();
})
.confirm(text(cx, "settings.storage.delete", &[]))
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |this, cx| this.clear_storage(kind, cx)).ok();
});
Some(dialog.into_any_element())
}
fn clear_storage(&mut self, kind: Kind, cx: &mut Context<Self>) {
let app = self.app;
let cleared = cx.background_executor().spawn(async move { storage::clear(app, kind) });
cx.spawn(async move |this, cx| {
let done = cleared.await;
this.update(cx, |this, cx| this.storage_done(done.map(|_| ()), cx))
})
.detach();
}
fn storage_done(&mut self, done: std::io::Result<()>, cx: &mut Context<Self>) {
self.storage.failed = done.err().map(|error| text(cx, "settings.storage.failed", &[("error", &error.to_string())]));
self.read_storage(cx);
}
/// Writes the shown limits with one changed; latest wins.
fn pick_limits(&mut self, change: impl FnOnce(Limits) -> Limits, cx: &mut Context<Self>) {
let Some(Ok(shown)) = self.storage.limits.clone() else {
return;
};
let wanted = change(shown);
self.storage.limits = Some(Ok(wanted));
let limiting = cx.default_global::<Limiting>();
limiting.pending = Some(wanted);
let asked = limiting.asked.fetch_add(1, Ordering::SeqCst) + 1;
let (app, latest) = (self.app, Arc::clone(&limiting.asked));
let kept = cx
.background_executor()
.spawn(async move { storage::set_limits(app, wanted, || latest.load(Ordering::SeqCst) == asked) });
// The global settles even when this window has closed.
cx.spawn(async move |this, cx| {
let done = kept.await;
let latest = cx.update(|cx| {
let failed = done.as_ref().err().map(|error| text(cx, "settings.storage.failed", &[("error", &error.to_string())]));
let limiting = cx.global_mut::<Limiting>();
let latest = limiting.asked.load(Ordering::SeqCst) == asked;
if latest {
limiting.failed = failed;
if done.is_ok() {
limiting.pending = None;
}
}
latest
});
if latest {
this.update(cx, |this, cx| this.read_storage(cx)).ok();
}
})
.detach();
cx.notify();
}
}
fn name(kind: Kind, cx: &App) -> SharedString {
match kind {
Kind::Log => text(cx, "settings.storage.log", &[]),
Kind::Cache => text(cx, "settings.storage.cache", &[]),
Kind::Recovery => text(cx, "settings.storage.recovery", &[]),
}
}
/// One kind: its size, cleared after a yes.
fn row(kind: Kind, bytes: u64, clearing: bool, this: WeakEntity<SettingsView>, cx: &App) -> AnyElement {
let size = text(cx, "settings.storage.size", &[("size", &bytes.div_ceil(1024).to_string())]);
let row = SettingsRow::new(name(kind, cx)).description(size);
if clearing {
let waiting = Button::new(SharedString::from(format!("storage-waiting-{kind:?}")), text(cx, "settings.storage.next_start", &[]));
return row.control(waiting.disabled(true)).into_any_element();
}
let label = match kind.at_next_start() {
true => text(cx, "settings.storage.clear_later", &[]),
false => text(cx, "settings.storage.clear", &[]),
};
let button = Button::new(SharedString::from(format!("storage-clear-{kind:?}")), label)
.disabled(bytes == 0)
.on_click(move |_, _, cx| {
this.update(cx, |this, cx| {
this.storage.confirming = Some(kind);
cx.notify();
})
.ok();
});
row.control(button).into_any_element()
}
/// A few sizes to pick from; picking writes the limits.
fn choices(
id: &'static str,
options: &[u64],
chosen: u64,
this: WeakEntity<SettingsView>,
change: fn(Limits, u64) -> Limits,
) -> SegmentedControl {
options
.iter()
.fold(SegmentedControl::new(id, chosen.to_string()), |control, n| {
control.segment(n.to_string(), n.to_string(), None)
})
.on_change(move |picked, _, cx| {
let Ok(n) = picked.parse::<u64>() else {
return log::error!("settings: {id} offered {picked}");
};
this.update(cx, |this, cx| this.pick_limits(|limits| change(limits, n), cx)).ok();
})
}
+50
View File
@@ -0,0 +1,50 @@
use ely_gpui_component::theme::{ActiveTheme as _, Mode};
use ely_platform::{
AppId,
settings::{Appearance, Shared},
};
use gpui::TestAppContext;
use super::{changed, window::SettingsView};
fn installed(cx: &mut TestAppContext) {
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
super::install_defaults(cx);
crate::theme::install(cx);
crate::i18n::install(cx);
});
}
#[gpui::test]
fn a_change_on_disk_turns_the_theme_at_once(cx: &mut TestAppContext) {
installed(cx);
let dark = Shared { appearance: Appearance::Dark, ..Shared::default() };
cx.update(|cx| changed((dark, None), cx));
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(cx.global::<crate::theme::ThemeSettings>().appearance, Appearance::Dark);
assert_eq!(cx.theme().mode(), Mode::Dark);
});
let light = Shared { appearance: Appearance::Light, ..Shared::default() };
cx.update(|cx| changed((light, None), cx));
cx.run_until_parked();
cx.update(|cx| assert_eq!(cx.theme().mode(), Mode::Light));
}
#[gpui::test]
fn each_app_shows_its_own_sections(cx: &mut TestAppContext) {
installed(cx);
for (app, keys) in [
(AppId::Doc, &["general", "documents", "storage", "diagnostics"][..]),
(AppId::Pdf, &["general", "documents", "storage", "diagnostics"]),
(AppId::Tree, &["general", "git", "storage", "diagnostics"]),
(AppId::Mark, &["general", "storage", "diagnostics"]),
] {
let window = cx.add_window(|window, cx| SettingsView::new(app, window, cx));
let shown = window
.update(cx, |view, _, cx| view.sections(cx).into_iter().map(|(key, ..)| key).collect::<Vec<_>>())
.unwrap();
assert_eq!(shown, keys, "{app:?}");
}
}
+351
View File
@@ -0,0 +1,351 @@
//! The settings window: shared settings and this app's own.
use ely_gpui_component::{
buttons::SegmentedControl,
feedback::Banner,
forms::{Input, InputEvent, Switch, TextInput},
primitives::{IconName, Severity},
settings::{self as kit, SettingsLayout, SettingsRow, SettingsSection, ThemeSelector},
};
use ely_platform::{
AppId,
settings::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word},
};
use gpui::{
AppContext as _, Context, Entity, FocusHandle, Global, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, WindowHandle, div,
};
use serde_json::Value;
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use super::{current, set};
use crate::{i18n::text, shell::Shell};
/// The open settings window, one per app.
#[derive(Clone, Copy)]
struct Opened(Option<WindowHandle<SettingsView>>);
impl Global for Opened {}
/// Opens the settings window, or brings it forward.
pub fn open(app: AppId, cx: &mut gpui::App) {
let shown = cx.try_global::<Opened>().and_then(|opened| opened.0);
if let Some(handle) = shown
&& handle.update(cx, |_, window, _| window.activate_window()).is_ok()
{
return;
}
let mut options = crate::run::window_options(app, gpui::size(gpui::px(720.0), gpui::px(520.0)), cx);
if let Some(bar) = options.titlebar.as_mut() {
bar.title = Some(text(cx, "settings.title", &[]));
}
match cx.open_window(options, |window, cx| cx.new(|cx| SettingsView::new(app, window, cx))) {
Ok(handle) => cx.set_global(Opened(Some(handle))),
Err(error) => log::error!("settings: the window did not open: {error:#}"),
}
}
pub struct SettingsView {
pub(super) app: AppId,
focus: FocusHandle,
section: SharedString,
author: Entity<TextInput>,
/// The name last read from or saved to the file.
author_saved: String,
/// The latest author commit; older writes are dropped.
author_asked: Arc<AtomicU64>,
git: Option<Entity<TextInput>>,
git_error: Option<SharedString>,
/// The latest Git commit; older checks are dropped.
git_asked: Arc<AtomicU64>,
pub(super) diagnostics: super::diagnostics::Diagnostics,
pub(super) storage: super::storage::Storage,
_fields: Vec<Subscription>,
}
impl SettingsView {
pub(super) fn new(app: AppId, window: &mut Window, cx: &mut Context<Self>) -> Self {
let author = cx.new(|cx| TextInput::new(window, cx).label(text(cx, "settings.author", &[])));
let account = ely_platform::user::account().unwrap_or_else(|error| {
log::warn!("settings: no account name: {error}");
String::new()
});
let named = current(cx).author.clone();
author.update(cx, |field, cx| {
field.set_placeholder(account, cx);
field.set_text(named.clone(), cx);
});
let mut fields = vec![
cx.subscribe(&author, |this, field, event: &InputEvent, cx| {
if matches!(event, InputEvent::Blur | InputEvent::Submit) {
this.commit_author(&field, cx);
}
}),
cx.observe_global::<super::Current>(|this, cx| this.follow_author(cx)),
];
let git = (app == AppId::Tree).then(|| cx.new(|cx| TextInput::new(window, cx).label(text(cx, "settings.git.path", &[]))));
let mut git_error = None;
if let Some(git) = &git {
let stored = git_setting().unwrap_or_else(|error| {
log::error!("settings: the Git setting is unreadable: {error}");
git_error = Some(text(cx, "settings.unreadable", &[("error", &error.to_string())]));
String::new()
});
git.update(cx, |field, cx| field.set_text(stored, cx));
fields.push(cx.subscribe(git, |this, field, event: &InputEvent, cx| {
if matches!(event, InputEvent::Blur | InputEvent::Submit) {
this.commit_git(&field, cx);
}
}));
}
let focus = cx.focus_handle();
window.focus(&focus, cx);
Self {
app,
focus,
section: "general".into(),
author,
author_saved: named,
author_asked: Arc::default(),
git,
git_error,
git_asked: Arc::default(),
diagnostics: Default::default(),
storage: Default::default(),
_fields: fields,
}
}
/// A name written elsewhere replaces an unedited field.
fn follow_author(&mut self, cx: &mut Context<Self>) {
let named = current(cx).author.clone();
let shown = self.author.read(cx).text().trim().to_string();
if named != self.author_saved && shown == self.author_saved {
self.author.update(cx, |field, cx| field.set_text(named.clone(), cx));
self.author_saved = named;
}
}
/// Writes an edit; a failed one stays for retry.
fn commit_author(&mut self, field: &Entity<TextInput>, cx: &mut Context<Self>) {
let typed = field.read(cx).text().trim().to_string();
// Every commit outdates the writes still waiting.
let asked = self.author_asked.fetch_add(1, Ordering::SeqCst) + 1;
if typed == self.author_saved {
return;
}
let turn = (Arc::clone(&self.author_asked), asked);
let write = super::saved(AUTHOR, Value::String(typed.clone()), Some(turn), cx);
cx.spawn(async move |this, cx| {
if write.await {
this.update(cx, |this, _| this.author_saved = typed).ok();
}
})
.detach();
}
/// Checks a system Git, then keeps it; latest wins.
fn commit_git(&mut self, field: &Entity<TextInput>, cx: &mut Context<Self>) {
let typed = field.read(cx).text().trim().to_string();
// Every commit outdates the checks still running.
let asked = self.git_asked.fetch_add(1, Ordering::SeqCst) + 1;
if git_setting().is_ok_and(|stored| stored == typed) {
self.git_error = None;
return cx.notify();
}
let latest = Arc::clone(&self.git_asked);
let check = cx.background_executor().spawn({
let typed = typed.clone();
async move {
if !typed.is_empty() {
ely_platform::git::system_version(std::path::Path::new(&typed))?;
}
// Under the file lock: a later commit skips this.
let still = || latest.load(Ordering::SeqCst) == asked;
Store::app(AppId::Tree)?.set_if(ely_platform::git::PROGRAM, Value::String(typed), still)
}
});
cx.spawn(async move |this, cx| {
let checked = check.await;
this.update(cx, |this, cx| {
if this.git_asked.load(Ordering::SeqCst) != asked {
return log::info!("settings: Git check {asked} was overtaken");
}
this.git_error = checked.err().map(|error| {
log::warn!("settings: Git setting refused: {error}");
text(cx, "settings.git.bad", &[("path", &typed), ("error", &error.to_string())])
});
cx.notify();
})
})
.detach();
}
pub(super) fn sections(&self, cx: &gpui::App) -> Vec<(&'static str, SharedString, IconName)> {
let mut sections = vec![("general", text(cx, "settings.general", &[]), IconName::Settings)];
if matches!(self.app, AppId::Doc | AppId::Table | AppId::Show | AppId::Pdf) {
sections.push(("documents", text(cx, "settings.documents", &[]), IconName::FileText));
}
if self.git.is_some() {
sections.push(("git", text(cx, "settings.git", &[]), IconName::GitBranch));
}
sections.push(("storage", text(cx, "settings.storage", &[]), IconName::HardDrive));
sections.push(("diagnostics", text(cx, "settings.diagnostics", &[]), IconName::Bug));
sections
}
fn general(&self, cx: &gpui::App) -> SettingsSection {
let shared = current(cx);
let appearance = match shared.appearance {
Appearance::System => kit::Appearance::System,
Appearance::Light => kit::Appearance::Light,
Appearance::Dark => kit::Appearance::Dark,
};
let theme = ThemeSelector::new("settings-appearance", appearance, |chosen, _, cx| {
let chosen = match chosen {
kit::Appearance::System => Appearance::System,
kit::Appearance::Light => Appearance::Light,
kit::Appearance::Dark => Appearance::Dark,
};
set(APPEARANCE, word(&Appearance::ALL, &chosen).into(), cx);
});
let language = choices("settings-language", &Language::ALL, &shared.language, |choice, cx| match choice {
Language::System => text(cx, "settings.language.system", &[]),
Language::English => "English".into(),
Language::Chinese => "简体中文".into(),
}, LANGUAGE, cx);
let motion = choices("settings-motion", &Motion::ALL, &shared.motion, |choice, cx| match choice {
Motion::System => text(cx, "settings.motion.system", &[]),
Motion::Reduced => text(cx, "settings.motion.reduce", &[]),
Motion::Full => text(cx, "settings.motion.full", &[]),
}, MOTION, cx);
let mut section = SettingsSection::new(text(cx, "settings.general", &[]))
.row(SettingsRow::new(text(cx, "settings.appearance", &[])).control(theme))
.row(
SettingsRow::new(text(cx, "settings.language", &[]))
.description(text(cx, "settings.language.next", &[]))
.control(language),
)
.row(SettingsRow::new(text(cx, "settings.motion", &[])).control(motion));
// Only where Omarchy is installed.
if ely_platform::omarchy_current_dir().is_some() {
let follow = Switch::new("settings-omarchy", shared.omarchy_sync)
.name(text(cx, "settings.omarchy", &[]))
.on_change(|on, _, cx| set(OMARCHY_SYNC, Value::Bool(on), cx));
section = section.row(SettingsRow::new(text(cx, "settings.omarchy", &[])).control(follow));
}
section
}
fn page(&self, this: gpui::WeakEntity<Self>, cx: &gpui::App) -> gpui::AnyElement {
match self.section.as_ref() {
"documents" => {
let autosave = Switch::new("settings-autosave", super::autosave::on(cx))
.name(text(cx, "settings.autosave", &[]))
.on_change(|on, _, cx| super::autosave::set(on, cx));
SettingsSection::new(text(cx, "settings.documents", &[]))
.row(
SettingsRow::new(text(cx, "settings.author", &[]))
.description(text(cx, "settings.author.hint", &[]))
.control(div().w_full().max_w_64().child(Input::new(&self.author))),
)
.row(
SettingsRow::new(text(cx, "settings.autosave", &[]))
.description(text(cx, "settings.autosave.hint", &[]))
.control(autosave),
)
.into_any_element()
}
"git" => {
let field = self.git.clone().expect("the git section is ElyTree's");
let section = SettingsSection::new(text(cx, "settings.git", &[])).row(
SettingsRow::new(text(cx, "settings.git.path", &[]))
.description(text(cx, "settings.git.hint", &[]))
.control(div().w_full().max_w_64().child(Input::new(&field).invalid(self.git_error.is_some()))),
);
match self.git_error.clone() {
Some(error) => section.row(Banner::new("settings-git-error", Severity::Danger, error)),
None => section,
}
.into_any_element()
}
"storage" => self.storage_page(this, cx),
"diagnostics" => self.diagnostics_page(this, cx),
_ => self.general(cx).into_any_element(),
}
}
}
/// A segmented choice that writes `key` when picked.
fn choices<T: Copy + PartialEq + 'static>(
id: &'static str,
all: &'static [(T, &'static str)],
chosen: &T,
label: impl Fn(&T, &gpui::App) -> SharedString,
key: &'static str,
cx: &gpui::App,
) -> SegmentedControl {
all.iter()
.fold(SegmentedControl::new(id, word(all, chosen)), |control, (choice, word)| {
control.segment(*word, label(choice, cx), None)
})
.on_change(move |picked, _, cx| set(key, Value::String(picked.to_string()), cx))
}
/// ElyTree's Git program; empty is its own.
fn git_setting() -> std::io::Result<String> {
match Store::app(AppId::Tree)?.read()?.get(ely_platform::git::PROGRAM) {
None => Ok(String::new()),
Some(Value::String(path)) => Ok(path.clone()),
Some(other) => Err(std::io::Error::other(format!("settings: git cannot be {other}"))),
}
}
impl Render for SettingsView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sections = self.sections(cx);
let this = cx.entity().downgrade();
let page = self.page(this.clone(), cx);
let dialog = self.storage_dialog(this.clone(), cx);
let layout = SettingsLayout::new("settings", sections, self.section.clone(), move |key, _, cx| {
let key = key.clone();
this.update(cx, |this, cx| {
if key == "diagnostics" {
this.read_diagnostics(cx);
}
if key == "storage" {
this.read_storage(cx);
}
this.section = key;
cx.notify();
})
.ok();
})
.page(page);
let current = cx.global::<super::Current>();
let unreadable = current.error.clone().map(|error| {
Banner::new("settings-unreadable", Severity::Danger, text(cx, "settings.unreadable", &[("error", &error)]))
});
let app_error: Vec<_> = current
.app_error
.iter()
.map(|(kind, error)| {
let id = SharedString::from(format!("settings-app-unreadable-{kind}"));
Banner::new(id, Severity::Danger, text(cx, "settings.app_unreadable", &[("error", error)]))
})
.collect();
let unsaved: Vec<_> = current
.unsaved
.iter()
.map(|(kind, error)| {
Banner::new(SharedString::from(format!("settings-unsaved-{kind}")), Severity::Danger, text(cx, "settings.unsaved", &[("error", error)]))
})
.collect();
Shell::new(text(cx, "settings.title", &[]), &self.focus)
.child(div().size_full().p_6().flex().flex_col().gap_4().children(unreadable).children(app_error).children(unsaved).child(layout))
.children(dialog)
}
}