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:
+205
-63
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user