feat(daemon): registry owns workspaces/groups/trees + running/stopped surfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:21:47 +07:00
parent 7515516699
commit d516414ac9
+205 -63
View File
@@ -1,24 +1,24 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering}; 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; use crate::surface::SurfaceHandle;
#[derive(Clone)] /// Single-threaded owner of structure (workspaces/groups/trees + per-surface
pub struct WorkspaceMeta { /// specs) and the live actor map. Lives in the server router task.
pub id: WorkspaceId,
pub path: PathBuf,
}
/// Single-threaded owner of all live surfaces and workspaces.
/// Lives inside the server task; not shared across threads.
#[derive(Default)] #[derive(Default)]
pub struct Registry { pub struct Registry {
counter: AtomicU64, counter: AtomicU64,
workspaces: HashMap<WorkspaceId, WorkspaceMeta>, groups: HashMap<GroupId, Group>,
/// path → workspace, so `open` is idempotent. workspaces: HashMap<WorkspaceId, Workspace>,
by_path: HashMap<PathBuf, WorkspaceId>, by_path: HashMap<String, WorkspaceId>,
surfaces: HashMap<SurfaceId, SurfaceHandle>, /// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
live: HashMap<SurfaceId, SurfaceHandle>,
} }
impl Registry { impl Registry {
@@ -31,75 +31,217 @@ impl Registry {
format!("{prefix}_{n:x}") 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 { pub fn new_surface_id(&self) -> SurfaceId {
SurfaceId(self.next_id("s")) SurfaceId(self.next_id("s"))
} }
pub fn insert_surface(&mut self, handle: SurfaceHandle) { // ---- workspaces ----
self.surfaces.insert(handle.id.clone(), handle);
/// 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> { pub fn workspace(&self, id: &WorkspaceId) -> Option<&Workspace> {
self.surfaces.get(id) 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<SurfaceId> {
let Some(ws) = self.workspaces.remove(id) else { return vec![] };
self.by_path.retain(|_, v| v != id);
let ids: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
for sid in &ids {
self.live.remove(sid);
}
ids
} }
pub fn remove_surface(&mut self, id: &SurfaceId) -> Option<SurfaceHandle> { /// The workspace that owns a surface id, if any.
self.surfaces.remove(id) pub fn workspace_of(&self, sid: &SurfaceId) -> Option<WorkspaceId> {
self.workspaces.values().find(|w| w.surfaces.contains_key(sid)).map(|w| w.id.clone())
} }
/// Snapshot for the `status` command: (workspace, its surface ids). // ---- surfaces (structure) ----
pub fn status(&self) -> Vec<(WorkspaceMeta, Vec<SurfaceId>)> {
self.workspaces pub fn add_surface_spec(&mut self, ws: &WorkspaceId, sid: SurfaceId, spec: SurfaceSpec) {
.values() if let Some(w) = self.workspaces.get_mut(ws) {
.map(|w| { w.surfaces.insert(sid, spec);
let sids = self }
.surfaces }
.values() pub fn surface_spec(&self, sid: &SurfaceId) -> Option<SurfaceSpec> {
.filter(|s| s.workspace_id == w.id) self.workspaces.values().find_map(|w| w.surfaces.get(sid).cloned())
.map(|s| s.id.clone()) }
.collect(); /// Remove a surface from its workspace's spec map and the tree.
(w.clone(), sids) pub fn remove_surface(&mut self, sid: &SurfaceId) {
}) self.live.remove(sid);
.collect() 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<Group> {
let mut g: Vec<Group> = self.groups.values().cloned().collect();
g.sort_by_key(|x| x.order);
g
}
// ---- views & persistence ----
pub fn workspace_view(&self, id: &WorkspaceId) -> Option<WorkspaceView> {
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<Group>, Vec<WorkspaceView>) {
let mut ws: Vec<WorkspaceView> = 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<Workspace> = 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use spacesh_proto::layout::{LayoutNode as LN, Orient};
#[test] fn spec() -> SurfaceSpec {
fn open_is_idempotent_per_path() { SurfaceSpec { command: "/bin/sh".into(), args: vec![], cwd: "/tmp".into(),
let mut reg = Registry::new(); agent_label: None, cols: 80, rows: 24, autostart: false }
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);
} }
#[test] #[test]
fn ids_are_unique_and_prefixed() { fn open_is_idempotent() {
let reg = Registry::new(); let mut r = Registry::new();
let s1 = reg.new_surface_id(); let (a, c1) = r.open_workspace(std::env::temp_dir());
let s2 = reg.new_surface_id(); let (b, c2) = r.open_workspace(std::env::temp_dir());
assert!(s1.0.starts_with("s_")); assert_eq!(a, b);
assert_ne!(s1, s2); 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());
} }
} }