diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index 872696b..cc0aefe 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -1,24 +1,24 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; -use spacesh_proto::{SurfaceId, WorkspaceId}; + +use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; +use spacesh_proto::layout::LayoutNode; +use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; + +use crate::state_store::PersistState; use crate::surface::SurfaceHandle; -#[derive(Clone)] -pub struct WorkspaceMeta { - pub id: WorkspaceId, - pub path: PathBuf, -} - -/// Single-threaded owner of all live surfaces and workspaces. -/// Lives inside the server task; not shared across threads. +/// Single-threaded owner of structure (workspaces/groups/trees + per-surface +/// specs) and the live actor map. Lives in the server router task. #[derive(Default)] pub struct Registry { counter: AtomicU64, - workspaces: HashMap, - /// path → workspace, so `open` is idempotent. - by_path: HashMap, - surfaces: HashMap, + groups: HashMap, + workspaces: HashMap, + by_path: HashMap, + /// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped. + live: HashMap, } impl Registry { @@ -31,75 +31,217 @@ impl Registry { format!("{prefix}_{n:x}") } - /// Idempotent: opening the same canonicalized path returns the existing workspace. - pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta { - let canonical = path.canonicalize().unwrap_or(path); - if let Some(id) = self.by_path.get(&canonical) { - return self.workspaces[id].clone(); - } - let id = WorkspaceId(self.next_id("w")); - let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() }; - self.workspaces.insert(id.clone(), meta.clone()); - self.by_path.insert(canonical, id); - meta - } - - pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> { - self.workspaces.get(id) - } - pub fn new_surface_id(&self) -> SurfaceId { SurfaceId(self.next_id("s")) } - pub fn insert_surface(&mut self, handle: SurfaceHandle) { - self.surfaces.insert(handle.id.clone(), handle); + // ---- workspaces ---- + + /// Idempotent by canonicalized path. Returns (workspace_id, created?). + pub fn open_workspace(&mut self, path: PathBuf) -> (WorkspaceId, bool) { + let canonical = path.canonicalize().unwrap_or(path); + let key = canonical.to_string_lossy().to_string(); + if let Some(id) = self.by_path.get(&key) { + return (id.clone(), false); + } + let id = WorkspaceId(self.next_id("w")); + let name = canonical.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| key.clone()); + 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(), + }); + self.by_path.insert(key, id.clone()); + (id, true) } - pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> { - self.surfaces.get(id) + pub fn workspace(&self, id: &WorkspaceId) -> Option<&Workspace> { + self.workspaces.get(id) + } + pub fn workspace_mut(&mut self, id: &WorkspaceId) -> Option<&mut Workspace> { + self.workspaces.get_mut(id) + } + pub fn close_workspace(&mut self, id: &WorkspaceId) -> Vec { + let Some(ws) = self.workspaces.remove(id) else { return vec![] }; + self.by_path.retain(|_, v| v != id); + let ids: Vec = ws.surfaces.keys().cloned().collect(); + for sid in &ids { + self.live.remove(sid); + } + ids } - pub fn remove_surface(&mut self, id: &SurfaceId) -> Option { - self.surfaces.remove(id) + /// The workspace that owns a surface id, if any. + pub fn workspace_of(&self, sid: &SurfaceId) -> Option { + self.workspaces.values().find(|w| w.surfaces.contains_key(sid)).map(|w| w.id.clone()) } - /// Snapshot for the `status` command: (workspace, its surface ids). - pub fn status(&self) -> Vec<(WorkspaceMeta, Vec)> { - self.workspaces - .values() - .map(|w| { - let sids = self - .surfaces - .values() - .filter(|s| s.workspace_id == w.id) - .map(|s| s.id.clone()) - .collect(); - (w.clone(), sids) - }) - .collect() + // ---- surfaces (structure) ---- + + pub fn add_surface_spec(&mut self, ws: &WorkspaceId, sid: SurfaceId, spec: SurfaceSpec) { + if let Some(w) = self.workspaces.get_mut(ws) { + w.surfaces.insert(sid, spec); + } + } + pub fn surface_spec(&self, sid: &SurfaceId) -> Option { + self.workspaces.values().find_map(|w| w.surfaces.get(sid).cloned()) + } + /// Remove a surface from its workspace's spec map and the tree. + pub fn remove_surface(&mut self, sid: &SurfaceId) { + self.live.remove(sid); + if let Some(ws) = self.workspace_of(sid) { + 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)); + } + } + } + + // ---- live actors ---- + + pub fn set_live(&mut self, handle: SurfaceHandle) { + self.live.insert(handle.id.clone(), handle); + } + pub fn live(&self, sid: &SurfaceId) -> Option<&SurfaceHandle> { + self.live.get(sid) + } + pub fn mark_stopped(&mut self, sid: &SurfaceId) { + self.live.remove(sid); + } + pub fn is_running(&self, sid: &SurfaceId) -> bool { + self.live.contains_key(sid) + } + + // ---- groups ---- + + pub fn create_group(&mut self, name: String, color: String) -> GroupId { + let id = GroupId(self.next_id("g")); + let order = self.groups.len() as u32; + self.groups.insert(id.clone(), Group { id: id.clone(), name, color, order }); + id + } + pub fn group_mut(&mut self, id: &GroupId) -> Option<&mut Group> { + self.groups.get_mut(id) + } + pub fn delete_group(&mut self, id: &GroupId) { + self.groups.remove(id); + for w in self.workspaces.values_mut() { + if w.group_id.as_ref() == Some(id) { + w.group_id = None; + } + } + } + pub fn groups(&self) -> Vec { + let mut g: Vec = self.groups.values().cloned().collect(); + g.sort_by_key(|x| x.order); + g + } + + // ---- views & persistence ---- + + pub fn workspace_view(&self, id: &WorkspaceId) -> Option { + let w = self.workspaces.get(id)?; + Some(self.to_view(w)) + } + fn to_view(&self, w: &Workspace) -> WorkspaceView { + let surfaces = w.surfaces.iter().map(|(sid, spec)| { + (sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) }) + }).collect(); + 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, + } + } + pub fn status(&self) -> (Vec, Vec) { + let mut ws: Vec = self.workspaces.values().map(|w| self.to_view(w)).collect(); + ws.sort_by_key(|w| w.order); + (self.groups(), ws) + } + pub fn persist_state(&self) -> PersistState { + let mut workspaces: Vec = self.workspaces.values().cloned().collect(); + workspaces.sort_by_key(|w| w.order); + PersistState { version: 1, groups: self.groups(), workspaces } + } + /// Replace all structure from a loaded snapshot (cold start). Clears live map. + pub fn restore(&mut self, state: PersistState) { + self.groups = state.groups.into_iter().map(|g| (g.id.clone(), g)).collect(); + self.workspaces.clear(); + self.by_path.clear(); + self.live.clear(); + for w in state.workspaces { + self.by_path.insert(w.path.clone(), w.id.clone()); + self.workspaces.insert(w.id.clone(), w); + } } } #[cfg(test)] mod tests { use super::*; + use spacesh_proto::layout::{LayoutNode as LN, Orient}; - #[test] - fn open_is_idempotent_per_path() { - let mut reg = Registry::new(); - let dir = std::env::temp_dir(); - let a = reg.open_workspace(dir.clone()); - let b = reg.open_workspace(dir.clone()); - assert_eq!(a.id, b.id); + fn spec() -> SurfaceSpec { + SurfaceSpec { command: "/bin/sh".into(), args: vec![], cwd: "/tmp".into(), + agent_label: None, cols: 80, rows: 24, autostart: false } } #[test] - fn ids_are_unique_and_prefixed() { - let reg = Registry::new(); - let s1 = reg.new_surface_id(); - let s2 = reg.new_surface_id(); - assert!(s1.0.starts_with("s_")); - assert_ne!(s1, s2); + fn open_is_idempotent() { + let mut r = Registry::new(); + let (a, c1) = r.open_workspace(std::env::temp_dir()); + let (b, c2) = r.open_workspace(std::env::temp_dir()); + assert_eq!(a, b); + assert!(c1 && !c2); + } + + #[test] + fn surface_running_then_stopped() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let sid = r.new_surface_id(); + r.add_surface_spec(&ws, sid.clone(), spec()); + assert!(!r.is_running(&sid)); // spec present, no live actor = stopped + let v = r.workspace_view(&ws).unwrap(); + assert_eq!(v.surfaces.get(&sid).unwrap().running, false); + } + + #[test] + fn remove_surface_updates_tree() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let s1 = r.new_surface_id(); + let s2 = r.new_surface_id(); + r.add_surface_spec(&ws, s1.clone(), spec()); + r.add_surface_spec(&ws, s2.clone(), spec()); + r.workspace_mut(&ws).unwrap().layout = Some(LN::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LN::leaf(s1.clone()), LN::leaf(s2.clone())], + }); + r.remove_surface(&s2); + let w = r.workspace(&ws).unwrap(); + assert!(!w.surfaces.contains_key(&s2)); + assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed + } + + #[test] + fn restore_round_trips_through_persist_state() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + r.add_surface_spec(&ws, r.new_surface_id(), spec()); + let state = r.persist_state(); + let mut r2 = Registry::new(); + r2.restore(state.clone()); + assert_eq!(r2.persist_state().workspaces.len(), state.workspaces.len()); + } + + #[test] + fn delete_group_ungroups_members() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let g = r.create_group("prod".into(), "#fff".into()); + r.workspace_mut(&ws).unwrap().group_id = Some(g.clone()); + r.delete_group(&g); + assert!(r.workspace(&ws).unwrap().group_id.is_none()); } }