T02: settings model, ranges, load and atomic save
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Successful in 9m49s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 11:26:34 -04:00
1 parent bcc799d76e
commit e1770ba8f9
7 files changed
+375 -1

No files matched your search

+3 -1
View File
@@ -16,7 +16,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
## 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
@@ -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: Git through the `git` command line, not libgit2.
- 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
View File
@@ -1749,11 +1749,14 @@ name = "elyterminal"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs 7.0.0",
"ely-gpui-component",
"env_logger",
"gpui-pre",
"gpui-pre-platform",
"log",
"serde",
"serde_json",
]
[[package]]
+3
View File
@@ -10,11 +10,14 @@ publish = false
[dependencies]
anyhow = "1"
dirs = "7"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
env_logger = "0.11"
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"] }
log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
+1
View File
@@ -0,0 +1 @@
pub mod settings;
+5
View File
@@ -3,6 +3,7 @@ use ely_gpui_component::{
primitives::FocusScope,
theme::{ActiveTheme, Mode, Theme},
};
use elyterminal::settings::SettingsStore;
use gpui::{
App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions,
Window, WindowBounds, WindowOptions, px, size,
@@ -37,6 +38,10 @@ fn main() {
.init();
application().with_assets(Assets).run(|cx: &mut App| {
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);
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
let options = WindowOptions {
+190
View File
@@ -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"
);
}
}
+170
View File
@@ -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");
}
}