diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index 83009b3..ea38600 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -122,6 +122,11 @@ pub enum Cmd { limit: Option, }, MarkRead { target: MarkReadTarget }, + SetZoom { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + surface_id: Option, + }, Health, Status, Shutdown, @@ -322,6 +327,18 @@ mod tests { assert_eq!(back, evt); } + #[test] + fn set_zoom_cmd_round_trips() { + let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } }; + let j = serde_json::to_string(&z).unwrap(); + assert!(j.contains(r#""cmd":"set_zoom""#)); + assert_eq!(serde_json::from_str::(&j).unwrap(), z); + let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: None } }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&unz).unwrap()).unwrap(), unz); + } + #[test] fn health_cmd_round_trips() { let env = Envelope::Req { id: 1, cmd: Cmd::Health }; diff --git a/crates/spacesh-proto/src/workspace.rs b/crates/spacesh-proto/src/workspace.rs index 5a0c28a..6dfc0bd 100644 --- a/crates/spacesh-proto/src/workspace.rs +++ b/crates/spacesh-proto/src/workspace.rs @@ -42,6 +42,9 @@ pub struct Workspace { /// None = empty workspace (no panels yet). #[serde(default)] pub layout: Option, + /// The single maximized surface for this workspace, if any. + #[serde(default)] + pub zoomed: Option, pub surfaces: HashMap, } @@ -66,6 +69,8 @@ pub struct WorkspaceView { pub order: u32, pub unread: bool, pub layout: Option, + #[serde(default)] + pub zoomed: Option, pub surfaces: HashMap, } @@ -99,10 +104,22 @@ mod tests { order: 0, unread: false, layout: None, + zoomed: None, surfaces: HashMap::new(), }; let j = serde_json::to_string(&w).unwrap(); let back: Workspace = serde_json::from_str(&j).unwrap(); assert_eq!(back, w); } + + #[test] + fn workspace_round_trips_with_zoom() { + let w = Workspace { + id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), + group_id: None, order: 0, unread: false, layout: None, + zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(), + }; + let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap(); + assert_eq!(back, w); + } } diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index a7a31ff..ac9f679 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -51,7 +51,7 @@ impl Registry { let order = self.workspaces.len() as u32; self.workspaces.insert(id.clone(), Workspace { id: id.clone(), path: key.clone(), name, group_id: None, order, - unread: false, layout: None, surfaces: HashMap::new(), + unread: false, layout: None, zoomed: None, surfaces: HashMap::new(), }); self.by_path.insert(key, id.clone()); (id, true) @@ -95,6 +95,7 @@ impl Registry { if let Some(w) = self.workspaces.get_mut(&ws) { w.surfaces.remove(sid); w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid)); + if w.zoomed.as_ref() == Some(sid) { w.zoomed = None; } } } } @@ -168,7 +169,7 @@ impl Registry { WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), group_id: w.group_id.clone(), order: w.order, unread: w.unread, - layout: w.layout.clone(), surfaces, + layout: w.layout.clone(), zoomed: w.zoomed.clone(), surfaces, } } pub fn status(&self) -> (Vec, Vec) { @@ -189,6 +190,10 @@ impl Registry { self.live.clear(); self.states.clear(); for w in state.workspaces { + let mut w = w; + if let Some(z) = &w.zoomed { + if !w.surfaces.contains_key(z) { w.zoomed = None; } + } self.by_path.insert(w.path.clone(), w.id.clone()); self.workspaces.insert(w.id.clone(), w); } @@ -277,6 +282,17 @@ mod tests { assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work); } + #[test] + fn remove_surface_clears_zoom() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let s1 = r.new_surface_id(); + r.add_surface_spec(&ws, s1.clone(), spec()); + r.workspace_mut(&ws).unwrap().zoomed = Some(s1.clone()); + r.remove_surface(&s1); + assert!(r.workspace(&ws).unwrap().zoomed.is_none()); + } + #[test] fn drop_state_resets_to_idle() { let mut r = Registry::new(); diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 3512c70..8cced31 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -572,7 +572,12 @@ async fn handle_request( crate::hooks::cleanup(&surface_id); crate::hooks::cleanup_shell(&surface_id); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() })); - if let Some(ws_id) = ws_id { emit_layout(reg, &ws_id, clients); } + if let Some(ws_id) = ws_id { + emit_layout(reg, &ws_id, clients); + if let Some(view) = reg.workspace_view(&ws_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); + } + } persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { @@ -594,6 +599,23 @@ async fn handle_request( } } + Cmd::SetZoom { workspace_id, surface_id } => { + let Some(w) = reg.workspace(&workspace_id) else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; + }; + if let Some(sid) = &surface_id { + if !w.surfaces.contains_key(sid) { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + } + } + reg.workspace_mut(&workspace_id).expect("workspace validated above").zoomed = surface_id.clone(); + if let Some(view) = reg.workspace_view(&workspace_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); + } + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Cmd::Health => { let _ = out.send(ok(id, serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), @@ -1335,4 +1357,102 @@ mod tests { let started = d["started_at_ms"].as_u64().unwrap(); assert!(started > 0 && started >= now.saturating_sub(5000) && started <= now + 1000, "started_at_ms plausible: {started} vs now {now}"); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_zoom_sets_and_clears_and_autoclears() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + + let _ = req(&mut s, 3, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let st = req(&mut s, 4, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert_eq!(w0["zoomed"], sid); + + let _ = req(&mut s, 5, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), surface_id: None, + }).await; + let st = req(&mut s, 6, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null()); + + let _ = req(&mut s, 7, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let _ = req(&mut s, 8, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + let st = req(&mut s, 9, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null(), "closing the zoomed surface clears zoom"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn close_zoomed_broadcasts_workspace_changed() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + + // Control connection: open, spawn, zoom. + let mut ctrl = UnixStream::connect(&sock).await.unwrap(); + let r = req(&mut ctrl, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut ctrl, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + let _ = req(&mut ctrl, 3, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + + // Observer connection: must be attached BEFORE the Close so it catches the broadcast. + let mut observer = UnixStream::connect(&sock).await.unwrap(); + + // Close the zoomed surface on the control connection. + let _ = req(&mut ctrl, 4, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + + // Observer must receive a WorkspaceChanged for this workspace with zoomed == None. + let mut saw_cleared = false; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if let Ok(Ok(Some(env))) = + tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await { + if let Envelope::Evt(Evt::WorkspaceChanged { workspace }) = env { + if workspace.id.0 == ws { + assert!(workspace.zoomed.is_none(), "WorkspaceChanged must report cleared zoom"); + saw_cleared = true; + break; + } + } + } + } + assert!(saw_cleared, "expected a WorkspaceChanged broadcast with cleared zoom after closing the zoomed surface"); + } } diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs index 31ca3c4..7a7c9b6 100644 --- a/crates/spaceshd/src/state_store.rs +++ b/crates/spaceshd/src/state_store.rs @@ -93,6 +93,7 @@ mod tests { order: 0, unread: false, layout: None, + zoomed: None, surfaces: std::collections::HashMap::new(), }], }