From ad296653524595c90d685e54b5706e70f45894e2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:24:51 +0700 Subject: [PATCH] feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast --- crates/spaceshd/src/config.rs | 34 +++++++++++++++++++++++++++ crates/spaceshd/src/server.rs | 44 +++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs index 3a251b6..3db549b 100644 --- a/crates/spaceshd/src/config.rs +++ b/crates/spaceshd/src/config.rs @@ -35,6 +35,27 @@ pub struct Config { } impl Config { + /// Resolve to a client-facing view, applying defaults and the shell resolver. + pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView { + spacesh_proto::config_view::ConfigView { + default_shell: self.resolved_shell(), + font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()), + font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20), + theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()), + accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()), + } + } + + /// Shell for a plain panel using THIS in-memory config + /// (env -> config -> passwd -> $SHELL -> /bin/sh). + pub fn resolved_shell(&self) -> String { + if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } } + if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } } + if let Some(s) = login_shell() { return s; } + if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } } + "/bin/sh".into() + } + /// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults. pub fn load() -> Self { let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() }; @@ -71,6 +92,8 @@ impl Config { /// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell /// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under /// launchd, where `$SHELL` is typically absent (so a bash fallback would win). +// retained for the env-override test and potential startup use +#[allow(dead_code)] pub fn default_shell() -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } @@ -158,6 +181,17 @@ mod tests { assert!(c.appearance.theme.is_none()); } + #[test] + fn to_view_applies_defaults_and_clamp() { + let mut c = Config::default(); + c.terminal.font_size = Some(99); + let v = c.to_view(); + assert_eq!(v.font_size, 20); + assert_eq!(v.theme, "dark"); + assert_eq!(v.accent, "blue"); + assert!(!v.font_family.is_empty()); + } + #[test] fn save_then_reload_round_trips() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id())); diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 74ac084..04a4bdd 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -135,6 +135,7 @@ async fn router( let mut clients: HashMap = HashMap::new(); // surface_id → set of client ids subscribed (attached). let mut subs: HashMap> = HashMap::new(); + let mut config = crate::config::Config::load(); while let Some(msg) = rx.recv().await { match msg { @@ -177,7 +178,7 @@ async fn router( ServerMsg::Request { id, cmd, client, out } => { handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &state_tx, &persister, - &mut event_log, &event_persister, started_at_ms).await; + &mut event_log, &event_persister, started_at_ms, &mut config).await; } } } @@ -276,6 +277,7 @@ async fn handle_request( event_log: &mut EventLog, event_persister: &EventPersister, started_at_ms: u64, + config: &mut crate::config::Config, ) { use spacesh_proto::message::SplitDir; use spacesh_proto::layout::{LayoutNode, Orient}; @@ -298,7 +300,7 @@ async fn handle_request( let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; let sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let spec = SurfaceSpec { command: shell, args: args.clone(), cwd: ws.path.clone(), agent_label: command, cols, rows, autostart: false, @@ -333,7 +335,7 @@ async fn handle_request( }; let ws = reg.workspace(&ws_id).cloned().unwrap(); let new_sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec); match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) { @@ -406,7 +408,7 @@ async fn handle_request( let slot = slots.get(i); let new_sid = reg.new_surface_id(); let command = slot.and_then(|s| s.command.clone()); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let args = slot.map(|s| s.args.clone()).unwrap_or_default(); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec); @@ -649,6 +651,40 @@ async fn handle_request( let _ = out.send(ok(id, serde_json::Value::Null)).await; std::process::exit(0); } + + Cmd::GetConfig => { + match serde_json::to_value(config.to_view()) { + Ok(v) => { let _ = out.send(ok(id, v)).await; } + Err(e) => { let _ = out.send(err(id, "INTERNAL", &e.to_string())).await; } + } + } + + Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => { + if let Some(v) = &theme { + if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; } + } + if let Some(v) = &accent { + const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"]; + if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; } + } + let mut next = config.clone(); + if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; } + if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; } + if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); } + if let Some(v) = theme { next.appearance.theme = Some(v); } + if let Some(v) = accent { next.appearance.accent = Some(v); } + let to_save = next.clone(); + match tokio::task::spawn_blocking(move || to_save.save()).await { + Ok(Ok(())) => { + *config = next; + let view = config.to_view(); + broadcast_evt(clients, &Envelope::Evt(Evt::ConfigChanged { config: view })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Ok(Err(e)) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; } + Err(e) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; } + } + } } }