feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast

This commit is contained in:
2026-06-14 09:24:51 +07:00
parent c4746f9864
commit ad29665352
2 changed files with 74 additions and 4 deletions
+34
View File
@@ -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()));
+40 -4
View File
@@ -135,6 +135,7 @@ async fn router(
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
// surface_id → set of client ids subscribed (attached).
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = 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; }
}
}
}
}