feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast
This commit is contained in:
@@ -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()));
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user