T02: settings model, ranges, load and atomic save
This commit is contained in:
7 files changed
+375
-1
No files matched your search
@@ -16,7 +16,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `src/main.rs`: start, window.
|
- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them.
|
||||||
|
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -33,3 +34,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- 2026-10-14: the working directory comes from OSC 7, read by a tap on the pty reader, sent by injected shell integration. vte drops OSC 7.
|
- 2026-10-14: the working directory comes from OSC 7, read by a tap on the pty reader, sent by injected shell integration. vte drops OSC 7.
|
||||||
- 2026-10-14: Git through the `git` command line, not libgit2.
|
- 2026-10-14: Git through the `git` command line, not libgit2.
|
||||||
- 2026-10-14: license MIT, as Ely.
|
- 2026-10-14: license MIT, as Ely.
|
||||||
|
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||||
Generated
+3
@@ -1749,11 +1749,14 @@ name = "elyterminal"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"dirs 7.0.0",
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"gpui-pre-platform",
|
"gpui-pre-platform",
|
||||||
"log",
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
dirs = "7"
|
||||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
|
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false }
|
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false }
|
||||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod settings;
|
||||||
@@ -3,6 +3,7 @@ use ely_gpui_component::{
|
|||||||
primitives::FocusScope,
|
primitives::FocusScope,
|
||||||
theme::{ActiveTheme, Mode, Theme},
|
theme::{ActiveTheme, Mode, Theme},
|
||||||
};
|
};
|
||||||
|
use elyterminal::settings::SettingsStore;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions,
|
App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions,
|
||||||
Window, WindowBounds, WindowOptions, px, size,
|
Window, WindowBounds, WindowOptions, px, size,
|
||||||
@@ -37,6 +38,10 @@ fn main() {
|
|||||||
.init();
|
.init();
|
||||||
application().with_assets(Assets).run(|cx: &mut App| {
|
application().with_assets(Assets).run(|cx: &mut App| {
|
||||||
ely_gpui_component::init(cx).expect("Ely failed to start");
|
ely_gpui_component::init(cx).expect("Ely failed to start");
|
||||||
|
if let Err(error) = SettingsStore::init(cx) {
|
||||||
|
log::error!("{error:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
Theme::set_mode_now(Mode::Dark, cx);
|
Theme::set_mode_now(Mode::Dark, cx);
|
||||||
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
||||||
let options = WindowOptions {
|
let options = WindowOptions {
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
mod store;
|
||||||
|
|
||||||
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use store::{SettingsStore, load, save};
|
||||||
|
|
||||||
|
/// Where a new session starts.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StartIn {
|
||||||
|
#[default]
|
||||||
|
Home,
|
||||||
|
Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CursorStyle {
|
||||||
|
#[default]
|
||||||
|
Block,
|
||||||
|
Bar,
|
||||||
|
Underline,
|
||||||
|
Hollow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which Option keys type Alt on macOS.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OptionAsAlt {
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the right panel shows.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PanelTab {
|
||||||
|
#[default]
|
||||||
|
Git,
|
||||||
|
Files,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the settings window edits. A field the file leaves out takes its default.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// A program path; empty runs the login shell.
|
||||||
|
pub shell: String,
|
||||||
|
pub start_in: StartIn,
|
||||||
|
pub theme: String,
|
||||||
|
/// The theme while the system is dark; none keeps `theme`.
|
||||||
|
pub dark_theme: Option<String>,
|
||||||
|
pub font_family: String,
|
||||||
|
pub font_size: f32,
|
||||||
|
pub line_height: f32,
|
||||||
|
pub ligatures: bool,
|
||||||
|
pub cursor_style: CursorStyle,
|
||||||
|
pub cursor_blink: bool,
|
||||||
|
pub background_opacity: f32,
|
||||||
|
pub background_blur: bool,
|
||||||
|
pub padding_x: f32,
|
||||||
|
pub padding_y: f32,
|
||||||
|
pub scrollback: u32,
|
||||||
|
pub option_as_alt: OptionAsAlt,
|
||||||
|
pub copy_on_select: bool,
|
||||||
|
pub show_sessions: bool,
|
||||||
|
pub show_panel: bool,
|
||||||
|
pub panel: PanelTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
shell: String::new(),
|
||||||
|
start_in: StartIn::Home,
|
||||||
|
theme: "catppuccin-mocha".into(),
|
||||||
|
dark_theme: None,
|
||||||
|
font_family: "JetBrains Mono".into(),
|
||||||
|
font_size: 13.0,
|
||||||
|
line_height: 1.2,
|
||||||
|
ligatures: true,
|
||||||
|
cursor_style: CursorStyle::Block,
|
||||||
|
cursor_blink: true,
|
||||||
|
background_opacity: 0.88,
|
||||||
|
background_blur: true,
|
||||||
|
padding_x: 10.0,
|
||||||
|
padding_y: 8.0,
|
||||||
|
scrollback: 10_000,
|
||||||
|
option_as_alt: OptionAsAlt::Off,
|
||||||
|
copy_on_select: false,
|
||||||
|
show_sessions: true,
|
||||||
|
show_panel: true,
|
||||||
|
panel: PanelTab::Git,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const FONT_SIZE: RangeInclusive<f32> = 6.0..=72.0;
|
||||||
|
pub const LINE_HEIGHT: RangeInclusive<f32> = 1.0..=2.0;
|
||||||
|
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
|
||||||
|
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
||||||
|
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Every value inside its range, every name filled; the error names the field.
|
||||||
|
pub fn validate(&self) -> anyhow::Result<()> {
|
||||||
|
within("font_size", self.font_size, &FONT_SIZE)?;
|
||||||
|
within("line_height", self.line_height, &LINE_HEIGHT)?;
|
||||||
|
within("background_opacity", self.background_opacity, &OPACITY)?;
|
||||||
|
within("padding_x", self.padding_x, &PADDING)?;
|
||||||
|
within("padding_y", self.padding_y, &PADDING)?;
|
||||||
|
within("scrollback", self.scrollback, &SCROLLBACK)?;
|
||||||
|
anyhow::ensure!(!self.theme.is_empty(), "theme is empty");
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.dark_theme.as_ref().is_none_or(|name| !name.is_empty()),
|
||||||
|
"dark_theme is empty"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn within<T: PartialOrd + std::fmt::Display>(
|
||||||
|
field: &str,
|
||||||
|
value: T,
|
||||||
|
range: &RangeInclusive<T>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
range.contains(&value),
|
||||||
|
"{field} is {value}, outside {} to {}",
|
||||||
|
range.start(),
|
||||||
|
range.end()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_valid() {
|
||||||
|
Settings::default().validate().expect("defaults pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_value_out_of_range_names_its_field() {
|
||||||
|
let settings = Settings {
|
||||||
|
line_height: 2.5,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let error = settings.validate().expect_err("2.5 is too tall");
|
||||||
|
assert_eq!(error.to_string(), "line_height is 2.5, outside 1 to 2");
|
||||||
|
let settings = Settings {
|
||||||
|
scrollback: 100_001,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let error = settings.validate().expect_err("too many lines");
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"scrollback is 100001, outside 0 to 100000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nan_is_out_of_range() {
|
||||||
|
let settings = Settings {
|
||||||
|
font_size: f32::NAN,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_name_fails() {
|
||||||
|
let settings = Settings {
|
||||||
|
dark_theme: Some(String::new()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
settings.validate().expect_err("empty").to_string(),
|
||||||
|
"dark_theme is empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{ErrorKind, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use gpui::{App, Global};
|
||||||
|
|
||||||
|
use super::Settings;
|
||||||
|
|
||||||
|
/// The settings in force and the file they live in; the only writer of that file.
|
||||||
|
pub struct SettingsStore {
|
||||||
|
settings: Settings,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Global for SettingsStore {}
|
||||||
|
|
||||||
|
impl SettingsStore {
|
||||||
|
/// Loads `settings.json` from the platform config folder into the app.
|
||||||
|
pub fn init(cx: &mut App) -> anyhow::Result<()> {
|
||||||
|
let folder = dirs::config_dir().context("settings: the platform names no config folder")?;
|
||||||
|
let path = folder.join("ELYTerminal").join("settings.json");
|
||||||
|
let settings = load(&path)?;
|
||||||
|
cx.set_global(Self { settings, path });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(cx: &App) -> &Settings {
|
||||||
|
&cx.global::<Self>().settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `edit`, checks the result and saves it; a bad edit changes nothing.
|
||||||
|
pub fn update(cx: &mut App, edit: impl FnOnce(&mut Settings)) -> anyhow::Result<()> {
|
||||||
|
let store = cx.global::<Self>();
|
||||||
|
let mut next = store.settings.clone();
|
||||||
|
edit(&mut next);
|
||||||
|
if next == store.settings {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
next.validate()?;
|
||||||
|
save(&store.path, &next)?;
|
||||||
|
cx.global_mut::<Self>().settings = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's settings; defaults when there is no file. A file that fails to read or check is an error.
|
||||||
|
pub fn load(path: &Path) -> anyhow::Result<Settings> {
|
||||||
|
let text = match fs::read_to_string(path) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
|
log::info!("settings: no file at {}, defaults", path.display());
|
||||||
|
return Ok(Settings::default());
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).with_context(|| format!("settings: cannot read {}", path.display()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settings: Settings = serde_json::from_str(&text)
|
||||||
|
.with_context(|| format!("settings: {} does not parse", path.display()))?;
|
||||||
|
settings
|
||||||
|
.validate()
|
||||||
|
.with_context(|| format!("settings: {} holds a bad value", path.display()))?;
|
||||||
|
log::info!("settings: loaded {}", path.display());
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a temp file beside `path`, then renames it over `path`.
|
||||||
|
pub fn save(path: &Path, settings: &Settings) -> anyhow::Result<()> {
|
||||||
|
let folder = path
|
||||||
|
.parent()
|
||||||
|
.with_context(|| format!("settings: {} has no folder", path.display()))?;
|
||||||
|
fs::create_dir_all(folder)
|
||||||
|
.with_context(|| format!("settings: cannot create {}", folder.display()))?;
|
||||||
|
let temp = path.with_extension("json.tmp");
|
||||||
|
let mut text = serde_json::to_string_pretty(settings).context("settings: cannot encode")?;
|
||||||
|
text.push('\n');
|
||||||
|
let mut file = fs::File::create(&temp)
|
||||||
|
.with_context(|| format!("settings: cannot create {}", temp.display()))?;
|
||||||
|
file.write_all(text.as_bytes())
|
||||||
|
.and_then(|()| file.sync_all())
|
||||||
|
.with_context(|| format!("settings: cannot write {}", temp.display()))?;
|
||||||
|
fs::rename(&temp, path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"settings: cannot move {} to {}",
|
||||||
|
temp.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
log::info!("settings: saved {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::settings::CursorStyle;
|
||||||
|
|
||||||
|
fn folder(name: &str) -> PathBuf {
|
||||||
|
let folder =
|
||||||
|
std::env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id()));
|
||||||
|
if folder.exists() {
|
||||||
|
fs::remove_dir_all(&folder).expect("a stale test folder goes");
|
||||||
|
}
|
||||||
|
folder
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_file_gives_defaults() {
|
||||||
|
let path = folder("none").join("settings.json");
|
||||||
|
assert_eq!(load(&path).expect("defaults"), Settings::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_file_loads_back_and_leaves_no_temp() {
|
||||||
|
let folder = folder("round");
|
||||||
|
let path = folder.join("settings.json");
|
||||||
|
let settings = Settings {
|
||||||
|
font_size: 15.5,
|
||||||
|
cursor_style: CursorStyle::Bar,
|
||||||
|
dark_theme: Some("catppuccin-latte".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save(&path, &settings).expect("saves");
|
||||||
|
assert_eq!(load(&path).expect("loads"), settings);
|
||||||
|
assert!(!path.with_extension("json.tmp").exists());
|
||||||
|
fs::remove_dir_all(folder).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_field_takes_its_default() {
|
||||||
|
let folder = folder("partial");
|
||||||
|
let path = folder.join("settings.json");
|
||||||
|
fs::create_dir_all(&folder).expect("folder");
|
||||||
|
fs::write(&path, r#"{ "font_size": 16.0 }"#).expect("write");
|
||||||
|
let settings = load(&path).expect("loads");
|
||||||
|
assert_eq!(settings.font_size, 16.0);
|
||||||
|
assert_eq!(settings.theme, Settings::default().theme);
|
||||||
|
fs::remove_dir_all(folder).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_file_is_an_error_naming_it() {
|
||||||
|
let folder = folder("bad");
|
||||||
|
let path = folder.join("settings.json");
|
||||||
|
fs::create_dir_all(&folder).expect("folder");
|
||||||
|
fs::write(&path, "{ font_size: ").expect("write");
|
||||||
|
let error = load(&path).expect_err("does not parse");
|
||||||
|
assert!(format!("{error:#}").contains("does not parse"));
|
||||||
|
assert!(error.to_string().contains("settings.json"));
|
||||||
|
fs::write(&path, r#"{ "cursor_style": "beam" }"#).expect("write");
|
||||||
|
assert!(format!("{:#}", load(&path).expect_err("unknown style")).contains("beam"));
|
||||||
|
fs::write(&path, r#"{ "font_sise": 12 }"#).expect("write");
|
||||||
|
assert!(format!("{:#}", load(&path).expect_err("unknown field")).contains("font_sise"));
|
||||||
|
fs::remove_dir_all(folder).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_value_is_an_error_naming_the_field() {
|
||||||
|
let folder = folder("range");
|
||||||
|
let path = folder.join("settings.json");
|
||||||
|
fs::create_dir_all(&folder).expect("folder");
|
||||||
|
fs::write(&path, r#"{ "background_opacity": 0.1 }"#).expect("write");
|
||||||
|
let error = load(&path).expect_err("too clear");
|
||||||
|
assert!(format!("{error:#}").contains("background_opacity is 0.1"));
|
||||||
|
fs::remove_dir_all(folder).expect("cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user