use std::fs;
use std::io::Write;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::app::{SIDEBAR_WIDTH_DEFAULT, SIDEBAR_WIDTH_MAX, SIDEBAR_WIDTH_MIN};
const CONFIG_VERSION: u32 = 1;
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub version: u32,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_lang")]
pub language: String,
#[serde(default = "default_shell_choice")]
pub shell: String,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: u16,
#[serde(default)]
pub sidebars: Option<SidebarsConfig>,
#[serde(default)]
pub layout: LayoutConfig,
#[serde(default)]
pub notifications: NotifyConfig,
#[serde(default)]
pub keybindings: std::collections::HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct LayoutConfig {
#[serde(default = "one")]
pub col_gap: u16,
#[serde(default)]
pub row_gap: u16,
#[serde(default = "yes")]
pub show_titles: bool,
#[serde(default = "yes", alias = "resume_in_new_node")]
pub resume_in_new_workspace: bool,
#[serde(default = "default_scrollback")]
pub scrollback: usize,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SidebarsConfig {
#[serde(default = "SideConfig::left_default")]
pub left: SideConfig,
#[serde(default = "SideConfig::right_default")]
pub right: SideConfig,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SideConfig {
#[serde(default = "yes")]
pub visible: bool,
#[serde(default = "default_sidebar_width")]
pub width: u16,
#[serde(default)]
pub docks: Vec<String>,
}
impl SideConfig {
pub fn left_default() -> SideConfig {
SideConfig {
visible: true,
width: SIDEBAR_WIDTH_DEFAULT,
docks: vec!["workspaces".into(), "agents".into()],
}
}
pub fn right_default() -> SideConfig {
SideConfig {
visible: false,
width: SIDEBAR_WIDTH_DEFAULT,
docks: Vec::new(),
}
}
}
impl SidebarsConfig {
pub fn default_layout() -> SidebarsConfig {
SidebarsConfig {
left: SideConfig::left_default(),
right: SideConfig::right_default(),
}
}
pub fn migrate(width: u16) -> SidebarsConfig {
let mut s = Self::default_layout();
s.left.width = width;
s
}
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct NotifyConfig {
#[serde(default)]
pub sound_on_done: bool,
#[serde(default)]
pub sound_on_blocked: bool,
}
fn default_theme() -> String {
"noir".to_string()
}
fn default_lang() -> String {
"en".to_string()
}
fn default_shell_choice() -> String {
"default".to_string()
}
fn default_sidebar_width() -> u16 {
SIDEBAR_WIDTH_DEFAULT
}
fn one() -> u16 {
1
}
fn yes() -> bool {
true
}
fn default_scrollback() -> usize {
SCROLLBACK_DEFAULT
}
impl Default for Config {
fn default() -> Self {
Config {
version: CONFIG_VERSION,
theme: default_theme(),
language: default_lang(),
shell: default_shell_choice(),
sidebar_width: default_sidebar_width(),
sidebars: None,
layout: LayoutConfig::default(),
notifications: NotifyConfig::default(),
keybindings: std::collections::HashMap::new(),
}
}
}
impl Default for LayoutConfig {
fn default() -> Self {
LayoutConfig {
col_gap: 1,
row_gap: 0,
show_titles: true,
resume_in_new_workspace: true,
scrollback: default_scrollback(),
}
}
}
pub const SCROLLBACK_DEFAULT: usize = 2_000;
pub const SCROLLBACK_MIN: usize = 200;
pub const SCROLLBACK_MAX: usize = 20_000;
pub const SCROLLBACK_STEP: usize = 200;
impl Config {
pub fn scrollback(&self) -> usize {
self.layout.scrollback.clamp(SCROLLBACK_MIN, SCROLLBACK_MAX)
}
pub fn sidebar_width(&self) -> u16 {
self.sidebar_width
.clamp(SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX)
}
pub fn sidebars(&self) -> SidebarsConfig {
self.sidebars
.clone()
.unwrap_or_else(|| SidebarsConfig::migrate(self.sidebar_width()))
}
}
fn config_path() -> PathBuf {
crate::persist::config_dir().join("config.json")
}
pub fn load() -> Config {
fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(cfg: &Config) {
let dir = crate::persist::ensure_config_dir();
if !dir.is_dir() {
return;
}
let Ok(json) = serde_json::to_string_pretty(cfg) else {
return;
};
let path = config_path();
let tmp = path.with_extension("json.tmp");
if let Ok(mut f) = fs::File::create(&tmp) {
if f.write_all(json.as_bytes()).is_ok() && f.flush().is_ok() {
let _ = fs::rename(&tmp, &path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_roundtrip() {
let c = Config::default();
assert_eq!(c.theme, "noir");
assert!(c.layout.show_titles);
assert_eq!(c.layout.col_gap, 1);
let from_empty: Config = serde_json::from_str("{}").unwrap();
assert_eq!(from_empty.theme, "noir");
assert_eq!(from_empty.sidebar_width, SIDEBAR_WIDTH_DEFAULT);
assert_eq!(c.layout.scrollback, 2_000);
assert_eq!(c.scrollback(), 2_000);
let mut wild = Config::default();
wild.layout.scrollback = 99_999_999;
assert_eq!(
wild.scrollback(),
SCROLLBACK_MAX,
"absurd values clamp down"
);
wild.layout.scrollback = 1;
assert_eq!(wild.scrollback(), SCROLLBACK_MIN, "tiny values clamp up");
let old: Config = serde_json::from_str(r#"{"layout":{"col_gap":1}}"#).unwrap();
assert_eq!(old.scrollback(), 2_000);
assert!(!c.notifications.sound_on_done);
assert!(!c.notifications.sound_on_blocked);
let c2 = Config {
theme: "mono".into(),
notifications: NotifyConfig {
sound_on_done: true,
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string(&c2).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.theme, "mono");
assert!(back.notifications.sound_on_done);
assert!(!back.notifications.sound_on_blocked);
}
}