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,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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(¢er)).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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user