From 114922aaf89a2a3ca52ae25522a337cc8c72378f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:11:51 +0700 Subject: [PATCH 01/17] feat(proto): GroupId, Orient, n-ary LayoutNode with external-tagged serde Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-proto/src/ids.rs | 9 +++++ crates/spacesh-proto/src/layout.rs | 58 ++++++++++++++++++++++++++++++ crates/spacesh-proto/src/lib.rs | 4 ++- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 crates/spacesh-proto/src/layout.rs diff --git a/crates/spacesh-proto/src/ids.rs b/crates/spacesh-proto/src/ids.rs index ee733e8..6968ef2 100644 --- a/crates/spacesh-proto/src/ids.rs +++ b/crates/spacesh-proto/src/ids.rs @@ -16,3 +16,12 @@ impl std::fmt::Display for WorkspaceId { write!(f, "{}", self.0) } } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GroupId(pub String); + +impl std::fmt::Display for GroupId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/spacesh-proto/src/layout.rs b/crates/spacesh-proto/src/layout.rs new file mode 100644 index 0000000..3080558 --- /dev/null +++ b/crates/spacesh-proto/src/layout.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use crate::ids::SurfaceId; + +/// Split orientation. `H` lays children left-to-right; `V` top-to-bottom. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Orient { + H, + V, +} + +/// Recursive n-ary layout tree. Externally tagged so JSON reads +/// `{ "leaf": { "surface_id": "s_1" } }` / `{ "split": { ... } }`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayoutNode { + Leaf { surface_id: SurfaceId }, + Split { + orient: Orient, + ratios: Vec, + children: Vec, + }, +} + +impl LayoutNode { + pub fn leaf(id: SurfaceId) -> Self { + LayoutNode::Leaf { surface_id: id } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaf_serializes_externally_tagged() { + let n = LayoutNode::leaf(SurfaceId("s_1".into())); + let j = serde_json::to_string(&n).unwrap(); + assert_eq!(j, r#"{"leaf":{"surface_id":"s_1"}}"#); + } + + #[test] + fn split_round_trips() { + let n = LayoutNode::Split { + orient: Orient::V, + ratios: vec![0.5, 0.5], + children: vec![ + LayoutNode::leaf(SurfaceId("s_1".into())), + LayoutNode::leaf(SurfaceId("s_2".into())), + ], + }; + let j = serde_json::to_string(&n).unwrap(); + assert!(j.contains(r#""split""#)); + assert!(j.contains(r#""orient":"v""#)); + let back: LayoutNode = serde_json::from_str(&j).unwrap(); + assert_eq!(back, n); + } +} diff --git a/crates/spacesh-proto/src/lib.rs b/crates/spacesh-proto/src/lib.rs index 23ec789..328a525 100644 --- a/crates/spacesh-proto/src/lib.rs +++ b/crates/spacesh-proto/src/lib.rs @@ -1,6 +1,8 @@ pub mod codec; pub mod ids; +pub mod layout; pub mod message; -pub use ids::{SurfaceId, WorkspaceId}; +pub use ids::{GroupId, SurfaceId, WorkspaceId}; +pub use layout::{LayoutNode, Orient}; pub use message::{Cmd, Envelope, ErrorBody, Evt}; From c8ba4010235d188b2b3ba3de0b570ac5d2ff905d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:12:29 +0700 Subject: [PATCH 02/17] feat(proto): SurfaceSpec, Group, Workspace, status view types Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-proto/src/lib.rs | 2 + crates/spacesh-proto/src/workspace.rs | 104 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 crates/spacesh-proto/src/workspace.rs diff --git a/crates/spacesh-proto/src/lib.rs b/crates/spacesh-proto/src/lib.rs index 328a525..cef3834 100644 --- a/crates/spacesh-proto/src/lib.rs +++ b/crates/spacesh-proto/src/lib.rs @@ -2,7 +2,9 @@ pub mod codec; pub mod ids; pub mod layout; pub mod message; +pub mod workspace; pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use layout::{LayoutNode, Orient}; pub use message::{Cmd, Envelope, ErrorBody, Evt}; +pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; diff --git a/crates/spacesh-proto/src/workspace.rs b/crates/spacesh-proto/src/workspace.rs new file mode 100644 index 0000000..23ffea7 --- /dev/null +++ b/crates/spacesh-proto/src/workspace.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use crate::ids::{GroupId, SurfaceId, WorkspaceId}; +use crate::layout::LayoutNode; + +/// Everything needed to (re)create a panel's process. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SurfaceSpec { + pub command: String, + #[serde(default)] + pub args: Vec, + pub cwd: String, + #[serde(default)] + pub agent_label: Option, + pub cols: u16, + pub rows: u16, + #[serde(default)] + pub autostart: bool, +} + +/// A colored, ordered collection of workspaces. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Group { + pub id: GroupId, + pub name: String, + pub color: String, + pub order: u32, +} + +/// Persisted workspace: structure only (no live process state). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Workspace { + pub id: WorkspaceId, + pub path: String, + pub name: String, + #[serde(default)] + pub group_id: Option, + pub order: u32, + #[serde(default)] + pub unread: bool, + /// None = empty workspace (no panels yet). + #[serde(default)] + pub layout: Option, + pub surfaces: HashMap, +} + +/// Per-surface view in `status` — spec plus live lifecycle flag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SurfaceView { + pub spec: SurfaceSpec, + /// true = has a live actor/PTY; false = stopped (in tree, no process). + pub running: bool, +} + +/// Workspace view in `status` / `workspace_changed`: structure + per-surface state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceView { + pub id: WorkspaceId, + pub path: String, + pub name: String, + pub group_id: Option, + pub order: u32, + pub unread: bool, + pub layout: Option, + pub surfaces: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn surface_spec_round_trips() { + let s = SurfaceSpec { + command: "claude".into(), + args: vec![], + cwd: "/tmp".into(), + agent_label: Some("claude".into()), + cols: 80, + rows: 24, + autostart: false, + }; + let j = serde_json::to_string(&s).unwrap(); + let back: SurfaceSpec = serde_json::from_str(&j).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn workspace_round_trips_with_empty_layout() { + let w = Workspace { + id: WorkspaceId("w_1".into()), + path: "/tmp/p".into(), + name: "p".into(), + group_id: None, + order: 0, + unread: false, + layout: 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); + } +} From 2723d40ff9acc8466062b0c78ce95ab5e4ba48e5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:13:33 +0700 Subject: [PATCH 03/17] feat(proto): M2 commands (split/ratios/move/preset/restart/groups/meta) and events Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-proto/src/message.rs | 130 +++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index 482e5c0..b8187a2 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; -use crate::ids::{SurfaceId, WorkspaceId}; +use crate::ids::{GroupId, SurfaceId, WorkspaceId}; +use crate::layout::LayoutNode; +use crate::workspace::{Group, WorkspaceView}; /// Wire envelope. `kind` is the serde tag. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -26,6 +28,33 @@ pub struct ErrorBody { pub msg: String, } +/// Direction a split grows the new neighbor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SplitDir { + Right, + Down, +} + +/// Edge of a target leaf to drop a moved panel against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +/// One panel slot when applying a preset. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PresetSlot { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, +} + /// Client → daemon commands. The active subset for M0+M1. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", content = "args", rename_all = "snake_case")] @@ -50,6 +79,41 @@ pub enum Cmd { Detach { surface_id: SurfaceId }, Focus { surface_id: SurfaceId }, Close { surface_id: SurfaceId }, + SplitSurface { + surface_id: SurfaceId, + dir: SplitDir, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + }, + SetRatios { workspace_id: WorkspaceId, node_path: Vec, ratios: Vec }, + MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge }, + ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec }, + RestartSurface { surface_id: SurfaceId }, + CloseWorkspace { workspace_id: WorkspaceId }, + SetWorkspaceMeta { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + group_id: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + unread: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + order: Option, + }, + CreateGroup { name: String, color: String }, + SetGroup { + group_id: GroupId, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + order: Option, + }, + DeleteGroup { group_id: GroupId }, Status, Shutdown, } @@ -62,6 +126,11 @@ pub enum Evt { Exit { surface_id: SurfaceId, code: i32 }, SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId }, SurfaceClosed { surface_id: SurfaceId }, + LayoutChanged { workspace_id: WorkspaceId, layout: Option }, + WorkspaceChanged { workspace: WorkspaceView }, + WorkspaceClosed { workspace_id: WorkspaceId }, + GroupsChanged { groups: Vec }, + SurfaceRestarted { surface_id: SurfaceId }, } #[cfg(test)] @@ -112,4 +181,63 @@ mod tests { _ => panic!("wrong variant"), } } + + #[test] + fn split_surface_serializes() { + let env = Envelope::Req { + id: 1, + cmd: Cmd::SplitSurface { + surface_id: SurfaceId("s_1".into()), + dir: SplitDir::Right, + command: None, + args: vec![], + }, + }; + let j = serde_json::to_string(&env).unwrap(); + assert!(j.contains("split_surface")); + assert!(j.contains(r#""dir":"right""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn apply_preset_round_trips() { + let env = Envelope::Req { + id: 2, + cmd: Cmd::ApplyPreset { + workspace_id: WorkspaceId("w_1".into()), + preset_id: "2x2".into(), + slots: vec![ + PresetSlot { command: Some("claude".into()), args: vec![] }, + PresetSlot { command: None, args: vec![] }, + ], + }, + }; + let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn set_ratios_round_trips() { + let env = Envelope::Req { + id: 3, + cmd: Cmd::SetRatios { + workspace_id: WorkspaceId("w_1".into()), + node_path: vec![0, 1], + ratios: vec![0.3, 0.7], + }, + }; + let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn layout_changed_event_round_trips() { + let evt = Envelope::Evt(Evt::LayoutChanged { + workspace_id: WorkspaceId("w_1".into()), + layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))), + }); + let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap(); + assert_eq!(back, evt); + } } From 28d0e05763aae876e1032216e82f528bbe0d1498 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:15:08 +0700 Subject: [PATCH 04/17] =?UTF-8?q?feat(core):=20n-ary=20tree=20ops=20?= =?UTF-8?q?=E2=80=94=20split,=20remove+collapse,=20ratios,=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/spacesh-core/Cargo.toml | 1 + crates/spacesh-core/src/lib.rs | 1 + crates/spacesh-core/src/ops.rs | 256 +++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 crates/spacesh-core/src/ops.rs diff --git a/Cargo.lock b/Cargo.lock index 3abca47..ed0f63b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,6 +719,7 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "serde", + "spacesh-proto", ] [[package]] diff --git a/crates/spacesh-core/Cargo.toml b/crates/spacesh-core/Cargo.toml index 01718ba..afe7f6e 100644 --- a/crates/spacesh-core/Cargo.toml +++ b/crates/spacesh-core/Cargo.toml @@ -6,3 +6,4 @@ version.workspace = true [dependencies] alacritty_terminal.workspace = true serde.workspace = true +spacesh-proto = { path = "../spacesh-proto" } diff --git a/crates/spacesh-core/src/lib.rs b/crates/spacesh-core/src/lib.rs index 107702d..ff47eea 100644 --- a/crates/spacesh-core/src/lib.rs +++ b/crates/spacesh-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod grid; +pub mod ops; pub mod snapshot; pub use grid::GridSurface; diff --git a/crates/spacesh-core/src/ops.rs b/crates/spacesh-core/src/ops.rs new file mode 100644 index 0000000..df23ff4 --- /dev/null +++ b/crates/spacesh-core/src/ops.rs @@ -0,0 +1,256 @@ +//! Pure algorithms over `spacesh_proto::LayoutNode`. No I/O. +use spacesh_proto::layout::{LayoutNode, Orient}; +use spacesh_proto::ids::SurfaceId; + +/// Minimum ratio a panel may shrink to (5%). +const MIN_RATIO: f32 = 0.05; + +/// Collect all surface ids in the tree, left-to-right. +pub fn leaves(node: &LayoutNode) -> Vec { + let mut out = Vec::new(); + collect(node, &mut out); + out +} +fn collect(node: &LayoutNode, out: &mut Vec) { + match node { + LayoutNode::Leaf { surface_id } => out.push(surface_id.clone()), + LayoutNode::Split { children, .. } => children.iter().for_each(|c| collect(c, out)), + } +} + +/// Split the leaf `target` by inserting `new_id` as a sibling on `dir`. +/// Returns true if the target was found and split. +pub fn split_leaf(root: &mut LayoutNode, target: &SurfaceId, dir: Orient, after: bool, new_id: SurfaceId) -> bool { + // If root itself is the target leaf, replace it with a split. + if let LayoutNode::Leaf { surface_id } = root { + if surface_id == target { + let existing = root.clone(); + let new_leaf = LayoutNode::leaf(new_id); + let children = if after { vec![existing, new_leaf] } else { vec![new_leaf, existing] }; + *root = LayoutNode::Split { orient: dir, ratios: even(children.len()), children }; + return true; + } + return false; + } + if let LayoutNode::Split { orient, ratios, children } = root { + // If a direct child is the target leaf AND this split matches `dir`, insert as sibling. + if *orient == dir { + if let Some(i) = children.iter().position(|c| is_leaf(c, target)) { + children.insert(i + if after { 1 } else { 0 }, LayoutNode::leaf(new_id)); + *ratios = even(children.len()); + return true; + } + } + // Otherwise recurse. + for c in children.iter_mut() { + if split_leaf(c, target, dir, after, new_id.clone()) { + return true; + } + } + } + false +} + +/// Remove the leaf `target`. Collapses empty/now-single-child splits and promotes +/// single children. Returns the new root (None if the tree became empty). +pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option { + match root { + LayoutNode::Leaf { surface_id } => { + if &surface_id == target { None } else { Some(LayoutNode::Leaf { surface_id }) } + } + LayoutNode::Split { orient, children, .. } => { + let kept: Vec = children + .into_iter() + .filter_map(|c| remove_leaf(c, target)) + .collect(); + match kept.len() { + 0 => None, + 1 => Some(kept.into_iter().next().unwrap()), // promote single child + n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }), + } + } + } +} + +/// Set ratios on the split node addressed by `path` (child indices from root). +/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the +/// path is invalid or the length does not match the node's child count. +pub fn set_ratios(root: &mut LayoutNode, path: &[u32], ratios: &[f32]) -> bool { + let Some(node) = node_at_mut(root, path) else { return false }; + if let LayoutNode::Split { ratios: r, children, .. } = node { + if ratios.len() != children.len() { + return false; + } + *r = normalize_clamp(ratios); + true + } else { + false + } +} + +/// Move leaf `src` to sit on `edge` of leaf `target`. Returns the new root. +/// No-op (returns the original) if src == target or either is missing. +pub fn move_leaf(root: LayoutNode, src: &SurfaceId, target: &SurfaceId, edge: spacesh_proto::message::Edge) -> LayoutNode { + use spacesh_proto::message::Edge; + if src == target || !contains(&root, src) || !contains(&root, target) { + return root; + } + let Some(removed) = remove_leaf(root, src) else { return LayoutNode::leaf(src.clone()) }; + let (orient, after) = match edge { + Edge::Left => (Orient::H, false), + Edge::Right => (Orient::H, true), + Edge::Top => (Orient::V, false), + Edge::Bottom => (Orient::V, true), + }; + let mut root = removed; + split_leaf(&mut root, target, orient, after, src.clone()); + root +} + +// ---- helpers ---- + +fn is_leaf(node: &LayoutNode, id: &SurfaceId) -> bool { + matches!(node, LayoutNode::Leaf { surface_id } if surface_id == id) +} +fn contains(node: &LayoutNode, id: &SurfaceId) -> bool { + leaves(node).iter().any(|s| s == id) +} +fn even(n: usize) -> Vec { + vec![1.0 / n as f32; n] +} +fn normalize_clamp(ratios: &[f32]) -> Vec { + // Two-pass: clamp all to MIN_RATIO, then normalize. If normalization would + // bring any value back below MIN_RATIO, pin those and redistribute the rest. + let n = ratios.len(); + if n == 0 { return vec![]; } + let mut r: Vec = ratios.iter().map(|v| v.max(MIN_RATIO)).collect(); + // Iteratively pin items that would end up below MIN_RATIO after normalization. + let mut pinned = vec![false; n]; + for _ in 0..n { + let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum(); + let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum(); + let remaining = 1.0 - pin_sum; + let mut changed = false; + for i in 0..n { + if pinned[i] { continue; } + let normalized = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 }; + if normalized < MIN_RATIO { + r[i] = MIN_RATIO; + pinned[i] = true; + changed = true; + } + } + if !changed { break; } + } + // Final normalization of unpinned values. + let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum(); + let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum(); + let remaining = (1.0 - pin_sum).max(0.0); + let mut result = vec![0.0f32; n]; + for i in 0..n { + if pinned[i] { + result[i] = r[i]; + } else { + result[i] = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 }; + } + } + result +} +fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[u32]) -> Option<&'a mut LayoutNode> { + let mut cur = root; + for &idx in path { + match cur { + LayoutNode::Split { children, .. } => { + cur = children.get_mut(idx as usize)?; + } + LayoutNode::Leaf { .. } => return None, + } + } + Some(cur) +} + +#[cfg(test)] +mod tests { + use super::*; + fn sid(s: &str) -> SurfaceId { SurfaceId(s.into()) } + + #[test] + fn split_root_leaf_creates_split() { + let mut root = LayoutNode::leaf(sid("s_1")); + assert!(split_leaf(&mut root, &sid("s_1"), Orient::H, true, sid("s_2"))); + assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2")]); + } + + #[test] + fn split_same_orient_appends_as_sibling() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + split_leaf(&mut root, &sid("s_2"), Orient::H, true, sid("s_3")); + // 3 children in one row, even ratios. + match &root { + LayoutNode::Split { children, ratios, .. } => { + assert_eq!(children.len(), 3); + assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); + } + _ => panic!(), + } + assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2"), sid("s_3")]); + } + + #[test] + fn remove_promotes_single_child() { + let root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + let after = remove_leaf(root, &sid("s_2")).unwrap(); + assert_eq!(after, LayoutNode::leaf(sid("s_1"))); // split collapsed to the surviving leaf + } + + #[test] + fn remove_last_leaf_returns_none() { + let root = LayoutNode::leaf(sid("s_1")); + assert!(remove_leaf(root, &sid("s_1")).is_none()); + } + + #[test] + fn set_ratios_normalizes_and_clamps() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + assert!(set_ratios(&mut root, &[], &[0.0, 1.0])); + if let LayoutNode::Split { ratios, .. } = &root { + assert!(ratios[0] >= MIN_RATIO); + assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); + } + } + + #[test] + fn set_ratios_wrong_len_rejected() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + assert!(!set_ratios(&mut root, &[], &[1.0])); + } + + #[test] + fn move_leaf_to_right_of_target() { + let root = LayoutNode::Split { + orient: Orient::V, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + let after = move_leaf(root, &sid("s_1"), &sid("s_2"), spacesh_proto::message::Edge::Right); + assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]); + } + + #[test] + fn move_onto_self_is_noop() { + let root = LayoutNode::leaf(sid("s_1")); + let after = move_leaf(root.clone(), &sid("s_1"), &sid("s_1"), spacesh_proto::message::Edge::Right); + assert_eq!(after, root); + } +} From 9927046c7e45683a5d6f7e8f55c522560057236c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:15:47 +0700 Subject: [PATCH 05/17] feat(core): 10 layout preset generators Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-core/src/lib.rs | 1 + crates/spacesh-core/src/presets.rs | 102 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 crates/spacesh-core/src/presets.rs diff --git a/crates/spacesh-core/src/lib.rs b/crates/spacesh-core/src/lib.rs index ff47eea..04b0164 100644 --- a/crates/spacesh-core/src/lib.rs +++ b/crates/spacesh-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod grid; pub mod ops; +pub mod presets; pub mod snapshot; pub use grid::GridSurface; diff --git a/crates/spacesh-core/src/presets.rs b/crates/spacesh-core/src/presets.rs new file mode 100644 index 0000000..6928dcd --- /dev/null +++ b/crates/spacesh-core/src/presets.rs @@ -0,0 +1,102 @@ +//! The 10 layout presets (DOCS/MAIN.md §8.2). A preset maps a list of surface +//! ids (one per slot, in order) to a LayoutNode. `slot_count` says how many +//! panels the preset needs. +use spacesh_proto::ids::SurfaceId; +use spacesh_proto::layout::{LayoutNode, Orient}; + +/// Known preset ids and their panel counts. +pub fn slot_count(preset_id: &str) -> Option { + Some(match preset_id { + "1" => 1, + "2lr" => 2, // 2↔ + "2tb" => 2, // 2↕ + "2+1" => 3, + "1+2" => 3, + "3" => 3, + "2x2" => 4, + "4" => 4, // single row of 4 + "2x3" => 6, + "2x4" => 8, + _ => return None, + }) +} + +fn leaf(id: &SurfaceId) -> LayoutNode { LayoutNode::leaf(id.clone()) } +fn even(n: usize) -> Vec { vec![1.0 / n as f32; n] } + +fn row(ids: &[SurfaceId]) -> LayoutNode { + LayoutNode::Split { orient: Orient::H, ratios: even(ids.len()), children: ids.iter().map(leaf).collect() } +} +fn col(children: Vec) -> LayoutNode { + LayoutNode::Split { orient: Orient::V, ratios: even(children.len()), children } +} +fn rown(children: Vec) -> LayoutNode { + LayoutNode::Split { orient: Orient::H, ratios: even(children.len()), children } +} + +/// Build the preset tree from exactly `slot_count(preset_id)` ids. +/// Returns None for an unknown id or wrong id count. +pub fn build(preset_id: &str, ids: &[SurfaceId]) -> Option { + if slot_count(preset_id)? != ids.len() { + return None; + } + Some(match preset_id { + "1" => leaf(&ids[0]), + "2lr" => row(&ids), + "2tb" => col(vec![leaf(&ids[0]), leaf(&ids[1])]), + // left big column over... 2 stacked on the right. + "2+1" => rown(vec![leaf(&ids[0]), col(vec![leaf(&ids[1]), leaf(&ids[2])])]), + // one big on the left, 2 stacked on the right (mirror naming kept simple). + "1+2" => rown(vec![col(vec![leaf(&ids[0]), leaf(&ids[1])]), leaf(&ids[2])]), + "3" => row(&ids), + "2x2" => col(vec![row(&ids[0..2]), row(&ids[2..4])]), + "4" => row(&ids), + "2x3" => col(vec![row(&ids[0..3]), row(&ids[3..6])]), + "2x4" => col(vec![row(&ids[0..4]), row(&ids[4..8])]), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::leaves; + fn ids(n: usize) -> Vec { (0..n).map(|i| SurfaceId(format!("s_{i}"))).collect() } + + #[test] + fn all_presets_have_counts() { + for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] { + assert!(slot_count(p).is_some(), "missing count for {p}"); + } + assert!(slot_count("nope").is_none()); + } + + #[test] + fn build_uses_all_ids_in_order() { + for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] { + let n = slot_count(p).unwrap(); + let tree = build(p, &ids(n)).unwrap(); + assert_eq!(leaves(&tree), ids(n), "preset {p} must place all ids in order"); + } + } + + #[test] + fn build_rejects_wrong_id_count() { + assert!(build("2x2", &ids(3)).is_none()); + assert!(build("bogus", &ids(1)).is_none()); + } + + #[test] + fn grid_2x2_is_two_rows() { + let tree = build("2x2", &ids(4)).unwrap(); + match tree { + LayoutNode::Split { orient: Orient::V, children, .. } => { + assert_eq!(children.len(), 2); + for r in &children { + matches!(r, LayoutNode::Split { orient: Orient::H, .. }); + } + } + _ => panic!("2x2 should be a vertical split of two horizontal rows"), + } + } +} From 4f7ed2a5a398e901c55ff55742ebd3e30e577607 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:20:51 +0700 Subject: [PATCH 06/17] feat(daemon): StateStore trait + atomic JSON store with corrupt-file backup Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/main.rs | 2 + crates/spaceshd/src/state_store.rs | 133 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 crates/spaceshd/src/state_store.rs diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index d26842b..ed7908f 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -1,7 +1,9 @@ mod launchd; mod lifecycle; +mod persist; mod registry; mod server; +mod state_store; mod surface; use anyhow::Result; diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs new file mode 100644 index 0000000..52ce86a --- /dev/null +++ b/crates/spaceshd/src/state_store.rs @@ -0,0 +1,133 @@ +use std::path::{Path, PathBuf}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use spacesh_proto::workspace::{Group, Workspace}; + +/// The full persisted snapshot of structure (no live processes). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct PersistState { + pub version: u32, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub workspaces: Vec, +} + +pub trait StateStore: Send + Sync { + fn load(&self) -> Result; + fn save(&self, state: &PersistState) -> Result<()>; +} + +/// JSON file store with atomic write (temp + rename) and corrupt-file backup. +pub struct JsonStateStore { + path: PathBuf, +} + +impl JsonStateStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + fn backup_corrupt(&self, ts: u128) { + let bak = self.path.with_extension(format!("corrupt-{ts}")); + let _ = std::fs::rename(&self.path, bak); + } +} + +impl StateStore for JsonStateStore { + fn load(&self) -> Result { + if !self.path.exists() { + return Ok(PersistState { version: 1, ..Default::default() }); + } + let bytes = std::fs::read(&self.path)?; + match serde_json::from_slice::(&bytes) { + Ok(state) => Ok(state), + Err(_) => { + // Corrupt file: back it up and start fresh. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + self.backup_corrupt(ts); + Ok(PersistState { version: 1, ..Default::default() }) + } + } + } + + fn save(&self, state: &PersistState) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = self.path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(state)?; + std::fs::write(&tmp, &bytes)?; + // fsync the temp file before rename for durability. + let f = std::fs::File::open(&tmp)?; + f.sync_all()?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[allow(dead_code)] +fn touch(_p: &Path) {} + +#[cfg(test)] +mod tests { + use super::*; + use spacesh_proto::ids::WorkspaceId; + + fn tmp_file(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spacesh-state-{name}-{n}.json")); + p + } + + fn sample() -> PersistState { + PersistState { + version: 1, + groups: vec![], + workspaces: vec![Workspace { + id: WorkspaceId("w_1".into()), + path: "/tmp/p".into(), + name: "p".into(), + group_id: None, + order: 0, + unread: false, + layout: None, + surfaces: std::collections::HashMap::new(), + }], + } + } + + #[test] + fn save_then_load_round_trips() { + let path = tmp_file("roundtrip"); + let store = JsonStateStore::new(path.clone()); + store.save(&sample()).unwrap(); + let back = store.load().unwrap(); + assert_eq!(back, sample()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn missing_file_loads_default() { + let store = JsonStateStore::new(tmp_file("missing")); + let s = store.load().unwrap(); + assert_eq!(s.version, 1); + assert!(s.workspaces.is_empty()); + } + + #[test] + fn corrupt_file_is_backed_up_and_load_returns_default() { + let path = tmp_file("corrupt"); + std::fs::write(&path, b"{ this is not valid json").unwrap(); + let store = JsonStateStore::new(path.clone()); + let s = store.load().unwrap(); + assert!(s.workspaces.is_empty()); + // original path no longer holds the corrupt bytes (renamed away) + assert!(!path.exists()); + let _ = std::fs::remove_file(path); + } +} From 7515516699c5daf6bb3a92b4f112e1470b700fcf Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:20:58 +0700 Subject: [PATCH 07/17] feat(daemon): debounced persist scheduler coalescing bursts into one save Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/persist.rs | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 crates/spaceshd/src/persist.rs diff --git a/crates/spaceshd/src/persist.rs b/crates/spaceshd/src/persist.rs new file mode 100644 index 0000000..3e6278f --- /dev/null +++ b/crates/spaceshd/src/persist.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant}; +use crate::state_store::{PersistState, StateStore}; + +/// Debounce window: coalesce a burst of dirty signals into one save. +const DEBOUNCE: Duration = Duration::from_millis(500); + +/// A handle the registry uses to request a persist. `mark_dirty(state)` records +/// the latest snapshot and (re)arms the debounce timer. +#[derive(Clone)] +pub struct Persister { + tx: mpsc::Sender, +} + +impl Persister { + pub fn mark_dirty(&self, state: PersistState) { + // Best-effort; dropping a snapshot is fine because a newer one will arrive. + let _ = self.tx.try_send(state); + } +} + +/// Spawn the debounce task. Returns the `Persister` handle. +/// `debounce` is configurable so tests can use a short window. +pub fn spawn(store: Arc, debounce: Duration) -> Persister { + let (tx, mut rx) = mpsc::channel::(64); + tokio::spawn(async move { + let mut latest: Option = None; + let mut deadline: Option = None; + loop { + let timer = async { + match deadline { + Some(d) => tokio::time::sleep_until(d).await, + None => std::future::pending::<()>().await, + } + }; + tokio::select! { + msg = rx.recv() => { + match msg { + Some(state) => { + latest = Some(state); + deadline = Some(Instant::now() + debounce); + } + None => { + // channel closed: final flush then exit + if let Some(s) = latest.take() { let _ = store.save(&s); } + break; + } + } + } + _ = timer => { + if let Some(s) = latest.take() { let _ = store.save(&s); } + deadline = None; + } + } + } + }); + Persister { tx } +} + +#[allow(dead_code)] +fn _unused(_c: &AtomicUsize) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct CountingStore { + saves: AtomicUsize, + last: Mutex>, + } + impl StateStore for CountingStore { + fn load(&self) -> anyhow::Result { Ok(PersistState::default()) } + fn save(&self, state: &PersistState) -> anyhow::Result<()> { + self.saves.fetch_add(1, Ordering::SeqCst); + *self.last.lock().unwrap() = Some(state.clone()); + Ok(()) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn burst_coalesces_to_one_save() { + let store = Arc::new(CountingStore { saves: AtomicUsize::new(0), last: Mutex::new(None) }); + let p = spawn(store.clone(), Duration::from_millis(80)); + // Fire several dirty signals rapidly. + for v in 1..=5u32 { + let mut s = PersistState::default(); + s.version = v; + p.mark_dirty(s); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // Wait past the debounce window. + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(store.saves.load(Ordering::SeqCst), 1, "burst should coalesce to one save"); + assert_eq!(store.last.lock().unwrap().as_ref().unwrap().version, 5, "save uses the latest snapshot"); + } +} From d516414ac9c91343445127d7ce382f31a1838dfc Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:21:47 +0700 Subject: [PATCH 08/17] feat(daemon): registry owns workspaces/groups/trees + running/stopped surfaces Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/registry.rs | 268 ++++++++++++++++++++++++-------- 1 file changed, 205 insertions(+), 63 deletions(-) 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()); } } From b72f4cb3a5c8e1d5458078227369d6ee4f2467af Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:22:29 +0700 Subject: [PATCH 09/17] feat(daemon): spawn_from_spec to (re)start surfaces from a persisted spec Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/surface.rs | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/spaceshd/src/surface.rs b/crates/spaceshd/src/surface.rs index bc68b14..0e739f4 100644 --- a/crates/spaceshd/src/surface.rs +++ b/crates/spaceshd/src/surface.rs @@ -1,10 +1,31 @@ use spacesh_core::{snapshot::snapshot_ansi, GridSurface}; use spacesh_core::snapshot::Snapshot; use spacesh_proto::{SurfaceId, WorkspaceId}; -use spacesh_pty::PtyHandle; +use spacesh_proto::workspace::SurfaceSpec; +use spacesh_pty::{PtyHandle, SpawnSpec}; use tokio::sync::{broadcast, mpsc, oneshot}; use tokio::time::{Duration, Instant}; +/// Spawn (or restart) a surface actor from a persisted spec. Injects +/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`. +pub fn spawn_from_spec( + id: SurfaceId, + workspace_id: WorkspaceId, + spec: &SurfaceSpec, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, +) -> std::io::Result { + let pty = PtyHandle::spawn(SpawnSpec { + command: spec.command.clone(), + args: spec.args.clone(), + cwd: std::path::PathBuf::from(&spec.cwd), + cols: spec.cols, + rows: spec.rows, + env: vec![("SPACESH_SURFACE_ID".into(), id.0.clone())], + }) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, exit_tx)) +} + const BROADCAST_CAP: usize = 1024; const FLUSH_INTERVAL: Duration = Duration::from_millis(6); const FLUSH_BYTES: usize = 16 * 1024; @@ -181,4 +202,29 @@ mod tests { let (snap, _sub) = reply_rx.await.unwrap(); assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn spawn_from_spec_runs_the_command() { + let _serial = crate::test_support::serial(); + let spec = SurfaceSpec { + command: "/bin/sh".into(), + args: vec!["-c".into(), "printf RESPAWN; sleep 0.3".into()], + cwd: std::env::temp_dir().to_string_lossy().into(), + agent_label: None, cols: 80, rows: 24, autostart: false, + }; + let (exit_tx, _rx) = mpsc::unbounded_channel(); + let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, exit_tx).unwrap(); + let (reply_tx, reply_rx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap(); + let mut sub = reply_rx.await.unwrap(); + let mut got = String::new(); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await { + got.push_str(&String::from_utf8_lossy(&b)); + if got.contains("RESPAWN") { break; } + } + } + assert!(got.contains("RESPAWN"), "got: {got:?}"); + } } From 62a65b691d6adb6b5771ac0f4ee658508d6d02bb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:25:35 +0700 Subject: [PATCH 10/17] feat(daemon): M2 command dispatch, layout events, cold-start restore, persistence wiring Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/main.rs | 5 +- crates/spaceshd/src/server.rs | 446 +++++++++++++++++++++++++++------- 2 files changed, 367 insertions(+), 84 deletions(-) diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index ed7908f..23a9f24 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -50,6 +50,9 @@ async fn run_daemon() -> Result<()> { }; lifecycle::clear_stale_socket()?; let sock = lifecycle::socket_path()?; + let state_path = lifecycle::spacesh_dir()?.join("state.json"); + let store: std::sync::Arc = + std::sync::Arc::new(state_store::JsonStateStore::new(state_path)); eprintln!("spaceshd listening on {}", sock.display()); - server::serve(&sock).await + server::serve(&sock, store).await } diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 5c82164..978a81c 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -1,14 +1,17 @@ use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use base64::Engine; use spacesh_proto::codec::{read_frame, write_frame}; -use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId}; -use spacesh_pty::{PtyHandle, SpawnSpec}; +use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{mpsc, oneshot}; +use crate::persist::{self, Persister}; use crate::registry::Registry; -use crate::surface::{spawn_surface, SurfaceMsg}; +use crate::state_store::StateStore; +use crate::surface::{SurfaceMsg}; /// Per-client outbound channel: the router pushes envelopes the client task writes out. type ClientTx = mpsc::Sender; @@ -29,7 +32,7 @@ enum ServerMsg { type ClientId = u64; -pub async fn serve(socket: &Path) -> Result<()> { +pub async fn serve(socket: &Path, store: Arc) -> Result<()> { let listener = UnixListener::bind(socket)?; let (router_tx, router_rx) = mpsc::channel::(256); @@ -42,7 +45,9 @@ pub async fn serve(socket: &Path) -> Result<()> { } }); - let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx)); + let persister = persist::spawn(store.clone(), Duration::from_millis(500)); + let initial = store.load().unwrap_or_default(); + let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, persister, initial)); let mut next_client: ClientId = 0; loop { @@ -97,8 +102,11 @@ async fn router( mut rx: mpsc::Receiver, router_tx: mpsc::Sender, exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + persister: Persister, + initial: crate::state_store::PersistState, ) { let mut reg = Registry::new(); + reg.restore(initial); let mut clients: HashMap = HashMap::new(); // surface_id → set of client ids subscribed (attached). let mut subs: HashMap> = HashMap::new(); @@ -125,11 +133,13 @@ async fn router( } } ServerMsg::Exit { surface_id, code } => { + // Transition running -> stopped; keep panel + tree. + reg.mark_stopped(&surface_id); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); broadcast_evt(&clients, &evt); } ServerMsg::Request { id, cmd, client, out } => { - handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await; + handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &persister).await; } } } @@ -149,6 +159,15 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope { error: Some(ErrorBody { code: code.into(), msg: msg.into() }) } } +/// Emit a `layout_changed` event for a workspace's current tree. +fn emit_layout(reg: &Registry, ws_id: &WorkspaceId, clients: &HashMap) { + if let Some(w) = reg.workspace(ws_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::LayoutChanged { + workspace_id: ws_id.clone(), layout: w.layout.clone(), + })); + } +} + #[allow(clippy::too_many_arguments)] async fn handle_request( id: u64, @@ -160,116 +179,299 @@ async fn handle_request( clients: &HashMap, router_tx: &mpsc::Sender, exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>, + persister: &Persister, ) { + use spacesh_proto::message::{SplitDir, Edge}; + use spacesh_proto::layout::{LayoutNode, Orient}; + use spacesh_proto::workspace::SurfaceSpec; + match cmd { Cmd::Open { path } => { - let meta = reg.open_workspace(path.into()); - let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await; + let (ws_id, created) = reg.open_workspace(path.into()); + if created { + 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::json!({ "workspace_id": ws_id.0 }))).await; } + Cmd::NewSurface { workspace_id, command, args, cols, rows } => { let Some(ws) = reg.workspace(&workspace_id).cloned() else { - let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; - return; + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; let sid = reg.new_surface_id(); - let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); - let spec = SpawnSpec { - command: shell, - args, - cwd: ws.path.clone(), - cols, - rows, - env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())], + let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let spec = SurfaceSpec { + command: shell, args: args.clone(), cwd: ws.path.clone(), + agent_label: command, cols, rows, autostart: false, }; - match PtyHandle::spawn(spec) { - Ok(pty) => { - let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone()); - // Bridge the surface's broadcast into the router as Output messages. + match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) { + Ok(handle) => { spawn_output_bridge(sid.clone(), &handle, router_tx.clone()); - reg.insert_surface(handle); - let created = Envelope::Evt(Evt::SurfaceCreated { + reg.set_live(handle); + reg.add_surface_spec(&workspace_id, sid.clone(), spec); + // First panel of an empty workspace becomes the root leaf. + if let Some(w) = reg.workspace_mut(&workspace_id) { + if w.layout.is_none() { + w.layout = Some(LayoutNode::leaf(sid.clone())); + } + } + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone(), - }); - broadcast_evt(clients, &created); + })); + emit_layout(reg, &workspace_id, clients); + persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await; } - Err(e) => { - let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; + Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } + } + } + + Cmd::SplitSurface { surface_id, dir, command, args } => { + let Some(ws_id) = reg.workspace_of(&surface_id) else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + }; + let ws = reg.workspace(&ws_id).cloned().unwrap(); + let new_sid = reg.new_surface_id(); + let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; + match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, exit_tx.clone()) { + Ok(handle) => { + spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); + reg.set_live(handle); + reg.add_surface_spec(&ws_id, new_sid.clone(), spec); + let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V }; + if let Some(w) = reg.workspace_mut(&ws_id) { + let mut root = w.layout.take().unwrap_or_else(|| LayoutNode::leaf(surface_id.clone())); + spacesh_core::ops::split_leaf(&mut root, &surface_id, orient, true, new_sid.clone()); + w.layout = Some(root); + } + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: ws_id.clone() })); + emit_layout(reg, &ws_id, clients); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::json!({ "surface_id": new_sid.0 }))).await; + } + Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } + } + } + + Cmd::SetRatios { workspace_id, node_path, ratios } => { + let ok_set = reg.workspace_mut(&workspace_id).map(|w| { + if let Some(l) = w.layout.as_mut() { + spacesh_core::ops::set_ratios(l, &node_path, &ratios) + } else { false } + }).unwrap_or(false); + if ok_set { + emit_layout(reg, &workspace_id, clients); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + let _ = out.send(err(id, "BAD_REQUEST", "invalid node_path or ratios")).await; + } + } + + Cmd::MoveSurface { surface_id, target_surface_id, edge } => { + let Some(ws_id) = reg.workspace_of(&surface_id) else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + }; + if let Some(w) = reg.workspace_mut(&ws_id) { + if let Some(root) = w.layout.take() { + w.layout = Some(spacesh_core::ops::move_leaf(root, &surface_id, &target_surface_id, edge)); } } + emit_layout(reg, &ws_id, clients); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; } + + Cmd::ApplyPreset { workspace_id, preset_id, slots } => { + let Some(count) = spacesh_core::presets::slot_count(&preset_id) else { + let _ = out.send(err(id, "BAD_REQUEST", "unknown preset")).await; return; + }; + let Some(ws) = reg.workspace(&workspace_id).cloned() else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; + }; + // Kill current panels of this workspace. + let existing: Vec = ws.surfaces.keys().cloned().collect(); + for sid in &existing { + if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; } + reg.remove_surface(sid); + subs.remove(sid); + } + // Spawn `count` panels (slots padded/truncated to count). + let mut new_ids = Vec::new(); + for i in 0..count { + 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(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + 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 }; + match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) { + Ok(handle) => { + spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); + reg.set_live(handle); + reg.add_surface_spec(&workspace_id, new_sid.clone(), spec); + new_ids.push(new_sid); + } + Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; } + } + } + if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) { + if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); } + } + for sid in &new_ids { + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() })); + } + emit_layout(reg, &workspace_id, clients); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::>() }))).await; + } + + Cmd::RestartSurface { surface_id } => { + if reg.is_running(&surface_id) { + let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running + } + let Some(spec) = reg.surface_spec(&surface_id) else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + }; + let ws_id = reg.workspace_of(&surface_id).unwrap(); + match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, exit_tx.clone()) { + Ok(handle) => { + spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone()); + reg.set_live(handle); + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } + } + } + + Cmd::CloseWorkspace { workspace_id } => { + let ids = reg.close_workspace(&workspace_id); + for sid in &ids { subs.remove(sid); } + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() })); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + + Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order } => { + let found = reg.workspace_mut(&workspace_id).map(|w| { + if let Some(n) = name { w.name = n; } + if let Some(g) = group_id { w.group_id = g; } + if let Some(u) = unread { w.unread = u; } + if let Some(o) = order { w.order = o; } + }).is_some(); + if found { + 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; + } else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; + } + } + + Cmd::CreateGroup { name, color } => { + let gid = reg.create_group(name, color); + broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::json!({ "group_id": gid.0 }))).await; + } + + Cmd::SetGroup { group_id, name, color, order } => { + let found = reg.group_mut(&group_id).map(|g| { + if let Some(n) = name { g.name = n; } + if let Some(c) = color { g.color = c; } + if let Some(o) = order { g.order = o; } + }).is_some(); + if found { + broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "group")).await; + } + } + + Cmd::DeleteGroup { group_id } => { + reg.delete_group(&group_id); + broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Cmd::Input { surface_id, bytes } => { let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else { - let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; - return; + let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; return; }; - if let Some(s) = reg.surface(&surface_id) { - let _ = s.tx.send(SurfaceMsg::Input(decoded)).await; + if let Some(s) = reg.live(&surface_id) { + let _ = s.tx.send(crate::surface::SurfaceMsg::Input(decoded)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } + Cmd::Resize { surface_id, cols, rows } => { - if let Some(s) = reg.surface(&surface_id) { - let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await; + if let Some(s) = reg.live(&surface_id) { + let _ = s.tx.send(crate::surface::SurfaceMsg::Resize { cols, rows }).await; let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } + Cmd::Attach { surface_id } => { - if let Some(s) = reg.surface(&surface_id) { + if let Some(s) = reg.live(&surface_id) { let (reply_tx, reply_rx) = oneshot::channel(); if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() { if let Ok((snap, _sub)) = reply_rx.await { subs.entry(surface_id.clone()).or_default().push(client); let _ = out.send(ok(id, serde_json::json!({ - "snapshot": snap.ansi, - "cols": snap.cols, - "rows": snap.rows, - "cursor_row": snap.cursor_row, - "cursor_col": snap.cursor_col, + "snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows, + "cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col, }))).await; return; } } let _ = out.send(err(id, "INTERNAL", "attach failed")).await; } else { - let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + // stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay. + let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await; } } + Cmd::Detach { surface_id } => { - if let Some(list) = subs.get_mut(&surface_id) { - list.retain(|c| *c != client); - } - let _ = out.send(ok(id, serde_json::Value::Null)).await; - } - Cmd::Focus { surface_id: _ } => { - // Focus is a no-op in this slice (window raise is GUI-side; CLI parity later). + if let Some(list) = subs.get_mut(&surface_id) { list.retain(|c| *c != client); } let _ = out.send(ok(id, serde_json::Value::Null)).await; } + + Cmd::Focus { surface_id: _ } => { let _ = out.send(ok(id, serde_json::Value::Null)).await; } + Cmd::Close { surface_id } => { - if let Some(handle) = reg.remove_surface(&surface_id) { - let _ = handle.tx.send(SurfaceMsg::Close).await; + if reg.surface_spec(&surface_id).is_some() { + if let Some(h) = reg.live(&surface_id) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; } + let ws_id = reg.workspace_of(&surface_id); + reg.remove_surface(&surface_id); subs.remove(&surface_id); - let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }); - broadcast_evt(clients, &closed); + 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); } + persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } + Cmd::Status => { - let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| { - serde_json::json!({ - "workspace_id": w.id.0, - "path": w.path.to_string_lossy(), - "surfaces": sids.iter().map(|s| s.0.clone()).collect::>(), - }) - }).collect(); - let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await; + let (groups, workspaces) = reg.status(); + let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; } + Cmd::Shutdown => { let _ = out.send(ok(id, serde_json::Value::Null)).await; std::process::exit(0); @@ -321,13 +523,36 @@ mod tests { } } + fn res_data(env: &Envelope) -> &serde_json::Value { + match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") } + } + + fn tempdir_path() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spaceshd-test-{n}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + async fn wait_for_socket(sock: &Path) { + for _ in 0..300 { + if UnixStream::connect(sock).await.is_ok() { return; } + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + } + panic!("socket never came up"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn open_new_surface_attach_streams_output() { 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 sock_for_task = sock.clone(); - tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); wait_for_socket(&sock).await; let mut s = UnixStream::connect(&sock).await.unwrap(); @@ -364,8 +589,11 @@ mod tests { 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 sock_for_task = sock.clone(); - tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); wait_for_socket(&sock).await; let mut s = UnixStream::connect(&sock).await.unwrap(); let r = req(&mut s, 1, Cmd::Input { @@ -381,33 +609,16 @@ mod tests { } } - fn res_data(env: &Envelope) -> &serde_json::Value { - match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") } - } - - fn tempdir_path() -> std::path::PathBuf { - let mut p = std::env::temp_dir(); - let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - p.push(format!("spaceshd-test-{n}")); - std::fs::create_dir_all(&p).unwrap(); - p - } - - async fn wait_for_socket(sock: &Path) { - for _ in 0..300 { - if UnixStream::connect(sock).await.is_ok() { return; } - tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; - } - panic!("socket never came up"); - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reattach_returns_snapshot_with_prior_output() { 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 sock_for_task = sock.clone(); - tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); wait_for_socket(&sock).await; // First client: open, new surface that prints a marker, attach, then disconnect. @@ -436,4 +647,73 @@ mod tests { let snap = res_data(&r)["snapshot"].as_str().unwrap(); assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}"); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn apply_preset_builds_tree_and_status_reports_it() { + 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 sock2 = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock2, 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::ApplyPreset { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + preset_id: "2x2".into(), + slots: vec![], + }).await; + let ids = res_data(&r)["surface_ids"].as_array().unwrap(); + assert_eq!(ids.len(), 4); + + let r = req(&mut s, 3, Cmd::Status).await; + let wss = res_data(&r)["workspaces"].as_array().unwrap(); + let w0 = wss.iter().find(|w| w["id"] == ws).unwrap(); + assert!(w0["layout"].is_object(), "layout tree present"); + assert!(w0["layout"].to_string().contains("split")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cold_restart_restores_structure_stopped() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let state_path = dir.join("state.json"); + let sock = dir.join("sock"); + let ws; + { + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); + let sock2 = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock2, 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; + ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + req(&mut s, 2, Cmd::ApplyPreset { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), preset_id: "2tb".into(), slots: vec![], + }).await; + // allow debounce (500ms) to flush state.json + tokio::time::sleep(tokio::time::Duration::from_millis(900)).await; + } + // "cold start": new store on the same state file, new socket. + let sock_b = dir.join("sock2"); + let store_b: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); + let sb2 = sock_b.clone(); + tokio::spawn(async move { let _ = serve(&sock_b, store_b).await; }); + wait_for_socket(&sb2).await; + let mut s2 = UnixStream::connect(&sb2).await.unwrap(); + let r = req(&mut s2, 1, Cmd::Status).await; + let wss = res_data(&r)["workspaces"].as_array().unwrap(); + let w0 = wss.iter().find(|w| w["id"] == ws).expect("workspace restored"); + let surfaces = w0["surfaces"].as_object().unwrap(); + assert_eq!(surfaces.len(), 2, "2tb panels restored"); + for (_id, sv) in surfaces { + assert_eq!(sv["running"], false, "restored panels are stopped"); + } + } } From ee2f7097ceff66a84b39f88bfbfcabf358de8148 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:28:54 +0700 Subject: [PATCH 11/17] feat(app): M2 layout TS types + bridge commands Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/layoutTypes.ts | 42 +++++++++++++++++++++++++++++ app/src/socketBridge.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 app/src/layoutTypes.ts diff --git a/app/src/layoutTypes.ts b/app/src/layoutTypes.ts new file mode 100644 index 0000000..e8ca7c0 --- /dev/null +++ b/app/src/layoutTypes.ts @@ -0,0 +1,42 @@ +export type Orient = "h" | "v"; + +export type LayoutNode = + | { leaf: { surface_id: string } } + | { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } }; + +export interface SurfaceView { + spec: { + command: string; + args: string[]; + cwd: string; + agent_label: string | null; + cols: number; + rows: number; + autostart: boolean; + }; + running: boolean; +} + +export interface Group { + id: string; + name: string; + color: string; + order: number; +} + +export interface WorkspaceView { + id: string; + path: string; + name: string; + group_id: string | null; + order: number; + unread: boolean; + layout: LayoutNode | null; + surfaces: Record; +} + +export function leafIds(node: LayoutNode | null): string[] { + if (!node) return []; + if ("leaf" in node) return [node.leaf.surface_id]; + return node.split.children.flatMap(leafIds); +} diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index 9688ee6..28c5062 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -1,5 +1,6 @@ import { invoke, Channel } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import type { Group, WorkspaceView } from "./layoutTypes"; export interface WorkspaceStatus { workspace_id: string; @@ -73,3 +74,62 @@ export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> { return listen(name, () => handler()); } + +// ---- M2 additions ---- + +export interface StatusResult { + groups: Group[]; + workspaces: WorkspaceView[]; +} + +export async function getStatusFull(): Promise { + return await invoke("status"); +} + +export async function splitSurface(surfaceId: string, dir: "right" | "down", command?: string, args: string[] = []): Promise { + const data = await invoke<{ surface_id: string }>("split_surface", { surfaceId, dir, command: command ?? null, args }); + return data.surface_id; +} + +export async function setRatios(workspaceId: string, nodePath: number[], ratios: number[]): Promise { + await invoke("set_ratios", { workspaceId, nodePath, ratios }); +} + +export async function moveSurface(surfaceId: string, targetSurfaceId: string, edge: "left" | "right" | "top" | "bottom"): Promise { + await invoke("move_surface", { surfaceId, targetSurfaceId, edge }); +} + +export async function applyPreset(workspaceId: string, presetId: string, slots: { command?: string; args?: string[] }[]): Promise { + const data = await invoke<{ surface_ids: string[] }>("apply_preset", { + workspaceId, presetId, + slots: slots.map((s) => ({ command: s.command ?? null, args: s.args ?? [] })), + }); + return data.surface_ids; +} + +export async function restartSurface(surfaceId: string): Promise { + await invoke("restart_surface", { surfaceId }); +} + +export async function closeWorkspaceCmd(workspaceId: string): Promise { + await invoke("close_workspace", { workspaceId }); +} + +export async function setWorkspaceMeta(workspaceId: string, meta: { name?: string; groupId?: string | null; unread?: boolean; order?: number }): Promise { + await invoke("set_workspace_meta", { + workspaceId, + name: meta.name ?? null, + groupId: meta.groupId === undefined ? null : meta.groupId, + unread: meta.unread ?? null, + order: meta.order ?? null, + }); +} + +export async function createGroup(name: string, color: string): Promise { + const data = await invoke<{ group_id: string }>("create_group", { name, color }); + return data.group_id; +} + +export async function closeSurfaceCmd(surfaceId: string): Promise { + await invoke("close_surface", { surfaceId }); +} From 4b88d269e34cf05a41975f44a8589fcf5c89c7b4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:29:26 +0700 Subject: [PATCH 12/17] =?UTF-8?q?feat(app):=20LayoutEngine=20=E2=80=94=20r?= =?UTF-8?q?ecursive=20split=20render,=20splitter=20resize,=20stopped=20ove?= =?UTF-8?q?rlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/LayoutEngine.tsx | 90 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/src/LayoutEngine.tsx diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx new file mode 100644 index 0000000..2ca517e --- /dev/null +++ b/app/src/LayoutEngine.tsx @@ -0,0 +1,90 @@ +import { useRef } from "react"; +import { TerminalView } from "./TerminalView"; +import type { LayoutNode } from "./layoutTypes"; +import { setRatios, restartSurface } from "./socketBridge"; + +interface Props { + workspaceId: string; + layout: LayoutNode | null; + /** surface_id -> running flag, from the latest status/events. */ + running: Record; +} + +export function LayoutEngine({ workspaceId, layout, running }: Props) { + if (!layout) { + return
Empty workspace — apply a preset to add panels.
; + } + return ; +} + +function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record }) { + if ("leaf" in node) { + const id = node.leaf.surface_id; + if (running[id] === false) { + return ( +
+
Process exited
+ +
+ ); + } + return ; + } + + const { orient, ratios, children } = node.split; + const dir = orient === "h" ? "row" : "column"; + return ( +
+ {children.map((child, i) => ( + { + const next = [...ratios]; + next[i] = Math.max(0.05, next[i] + deltaFrac); + next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac); + void setRatios(workspaceId, path, next); + }}> + + + ))} +
+ ); +} + +function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) { + const ref = useRef(null); + const startDrag = (e: React.MouseEvent) => { + e.preventDefault(); + const parent = ref.current?.parentElement; + if (!parent) return; + const total = orient === "h" ? parent.clientWidth : parent.clientHeight; + const start = orient === "h" ? e.clientX : e.clientY; + let last = start; + const move = (ev: MouseEvent) => { + const cur = orient === "h" ? ev.clientX : ev.clientY; + const delta = (cur - last) / total; + last = cur; + onResize(delta); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + }; + return ( + <> +
+ {children} +
+ {!isLast && ( +
+ )} + + ); +} From 0320a2f313ae74953318f1fd4b45e116cf42c0bd Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:30:26 +0700 Subject: [PATCH 13/17] feat(app): tauri bridge commands for M2 (split/ratios/move/preset/restart/groups/meta) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/bridge.rs | 52 +++++++++++++++++++++++++++++++++++++ app/src-tauri/src/lib.rs | 8 ++++++ 2 files changed, 60 insertions(+) diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index f131ff7..6621910 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -7,6 +7,8 @@ use anyhow::{Context, Result}; use base64::Engine; use serde_json::Value; use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::message::{SplitDir, Edge, PresetSlot}; +use spacesh_proto::ids::{GroupId, WorkspaceId}; use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId}; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter}; @@ -193,3 +195,53 @@ pub async fn status(state: BridgeState<'_>) -> Result { pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result { data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) } + +// ---- M2 commands ---- + +#[tauri::command] +pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option, args: Vec) -> Result { + let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right }; + data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec, ratios: Vec) -> Result { + data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result { + let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right }; + data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec) -> Result { + data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result { + data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result { + data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option, group_id: Option, unread: Option, order: Option) -> Result { + // group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup". + let gid = match group_id { + None => None, + Some(s) if s.is_empty() => Some(None), + Some(s) => Some(Some(GroupId(s))), + }; + data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result { + data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b99c245..8cd9ffd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -25,6 +25,14 @@ pub fn run() { bridge::detach, bridge::status, bridge::close_surface, + bridge::split_surface, + bridge::set_ratios, + bridge::move_surface, + bridge::apply_preset, + bridge::restart_surface, + bridge::close_workspace, + bridge::set_workspace_meta, + bridge::create_group, ]) .run(tauri::generate_context!()) .expect("error while running spacesh"); From 7ec0c8468562a82117257d854e9db21cf21eee27 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:31:49 +0700 Subject: [PATCH 14/17] feat(app): sidebar, preset picker, wizard, App rewired around workspaces + LayoutEngine Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 99 +++++++++++++++++----------------------- app/src/PresetPicker.tsx | 30 ++++++++++++ app/src/Sidebar.tsx | 44 ++++++++++++++++++ app/src/Wizard.tsx | 46 +++++++++++++++++++ 4 files changed, 162 insertions(+), 57 deletions(-) create mode 100644 app/src/PresetPicker.tsx create mode 100644 app/src/Sidebar.tsx create mode 100644 app/src/Wizard.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 2a0bccc..8e1f0da 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,68 +1,53 @@ -import { useEffect, useState } from "react"; -import { TerminalView } from "./TerminalView"; -import { SurfaceList } from "./SurfaceList"; -import { openWorkspace, newSurface, getStatus, onDaemonEvent, onDaemonRawEvent } from "./socketBridge"; +import { useEffect, useState, useCallback } from "react"; +import { LayoutEngine } from "./LayoutEngine"; +import { Sidebar } from "./Sidebar"; +import { PresetPicker } from "./PresetPicker"; +import { Wizard } from "./Wizard"; +import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge"; +import type { Group, WorkspaceView } from "./layoutTypes"; export function App() { - const [surfaces, setSurfaces] = useState([]); - const [active, setActive] = useState(null); - const [workspaceId, setWorkspaceId] = useState(null); + const [groups, setGroups] = useState([]); + const [workspaces, setWorkspaces] = useState([]); + const [activeId, setActiveId] = useState(null); + const [running, setRunning] = useState>({}); + const [wizard, setWizard] = useState(false); + + const refresh = useCallback(async () => { + const st = await getStatusFull(); + setGroups(st.groups); + setWorkspaces(st.workspaces); + const run: Record = {}; + st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; })); + setRunning(run); + if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id); + }, [activeId]); useEffect(() => { - void (async () => { - const ws = await getStatus(); - const flat = ws.flatMap((w) => w.surfaces); - setSurfaces(flat); - if (flat.length) setActive(flat[0]); - })(); + void refresh(); + const unlisten = onDaemonEvent(() => { void refresh(); }); + const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); }); + return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; + }, [refresh]); - const unlisten = onDaemonEvent((evt) => { - if (evt.evt === "surface_created") { - setSurfaces((s) => [...s, evt.data.surface_id]); - } else if (evt.evt === "surface_closed" || evt.evt === "exit") { - // exit leaves the surface visible; surface_closed removes it. - if (evt.evt === "surface_closed") { - setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id)); - } - } - }); - - const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { - // Force a remount of the active TerminalView by toggling the key. - setActive((cur) => cur); - void getStatus().then((ws) => { - const flat = ws.flatMap((w) => w.surfaces); - setSurfaces(flat); - }); - }); - - return () => { - void unlisten.then((f) => f()); - void reconnect.then((f) => f()); - }; - }, []); - - async function handleNewSurface() { - let ws = workspaceId; - if (!ws) { - ws = await openWorkspace("."); - setWorkspaceId(ws); - } - const id = await newSurface(ws, 80, 24); - setActive(id); - } + const active = workspaces.find((w) => w.id === activeId) ?? null; return ( -
-
- - -
-
- {active ? :
no surface
} +
+ setWizard(true)} /> +
+ {active && ( +
+ { if (active) void applyPreset(active.id, p, []); }} /> +
+ )} +
+ {active + ? + :
No workspace — create one to begin.
} +
+ {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
); } diff --git a/app/src/PresetPicker.tsx b/app/src/PresetPicker.tsx new file mode 100644 index 0000000..5b25e13 --- /dev/null +++ b/app/src/PresetPicker.tsx @@ -0,0 +1,30 @@ +export const PRESETS: { id: string; label: string; slots: number }[] = [ + { id: "1", label: "1", slots: 1 }, + { id: "2lr", label: "2↔", slots: 2 }, + { id: "2tb", label: "2↕", slots: 2 }, + { id: "2+1", label: "2+1", slots: 3 }, + { id: "1+2", label: "1+2", slots: 3 }, + { id: "3", label: "3", slots: 3 }, + { id: "2x2", label: "2×2", slots: 4 }, + { id: "4", label: "4", slots: 4 }, + { id: "2x3", label: "2×3", slots: 6 }, + { id: "2x4", label: "2×4", slots: 8 }, +]; + +export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) { + return ( +
+ {PRESETS.map((p) => ( + + ))} +
+ ); +} diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx new file mode 100644 index 0000000..d85d8fa --- /dev/null +++ b/app/src/Sidebar.tsx @@ -0,0 +1,44 @@ +import type { Group, WorkspaceView } from "./layoutTypes"; + +export function Sidebar({ + groups, workspaces, activeId, onSelect, onNew, +}: { + groups: Group[]; + workspaces: WorkspaceView[]; + activeId: string | null; + onSelect: (id: string) => void; + onNew: () => void; +}) { + const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); + const ungrouped = byGroup(null); + + const row = (w: WorkspaceView) => ( +
onSelect(w.id)} + style={{ + display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer", + background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13, + color: w.id === activeId ? "#E6EDF3" : "#8B97A6", + }}> + + {w.name} + {w.unread && } + {Object.keys(w.surfaces).length} +
+ ); + + return ( +
+ + {groups.sort((a, b) => a.order - b.order).map((g) => ( +
+
+ + {g.name.toUpperCase()} +
+ {byGroup(g.id).map(row)} +
+ ))} + {ungrouped.length > 0 &&
{ungrouped.map(row)}
} +
+ ); +} diff --git a/app/src/Wizard.tsx b/app/src/Wizard.tsx new file mode 100644 index 0000000..fbee211 --- /dev/null +++ b/app/src/Wizard.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { PresetPicker, PRESETS } from "./PresetPicker"; +import { openWorkspace, applyPreset } from "./socketBridge"; + +export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) { + const [path, setPath] = useState("."); + const [preset, setPreset] = useState("2x2"); + const [agents, setAgents] = useState([]); + const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; + const agentChoices = ["shell", "claude", "codex", "gemini"]; + + async function create() { + const ws = await openWorkspace(path); + const slotSpecs = Array.from({ length: slots }, (_, i) => { + const a = agents[i] ?? "shell"; + return a === "shell" ? {} : { command: a }; + }); + await applyPreset(ws, preset, slotSpecs); + onDone(ws); + } + + return ( +
+
+
New workspace
+ + setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} /> + +
+ +
+ {Array.from({ length: slots }, (_, i) => ( + + ))} +
+
+ + +
+
+
+ ); +} From 0328797bcea16faa213f11b46154f5f7a9bc1167 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:39:43 +0700 Subject: [PATCH 15/17] chore(daemon): remove unused imports and dead placeholders Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/persist.rs | 8 +------- crates/spaceshd/src/registry.rs | 1 - crates/spaceshd/src/server.rs | 2 +- crates/spaceshd/src/state_store.rs | 5 +---- crates/spaceshd/src/surface.rs | 1 + 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/crates/spaceshd/src/persist.rs b/crates/spaceshd/src/persist.rs index 3e6278f..796b1bd 100644 --- a/crates/spaceshd/src/persist.rs +++ b/crates/spaceshd/src/persist.rs @@ -1,12 +1,8 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use crate::state_store::{PersistState, StateStore}; -/// Debounce window: coalesce a burst of dirty signals into one save. -const DEBOUNCE: Duration = Duration::from_millis(500); - /// A handle the registry uses to request a persist. `mark_dirty(state)` records /// the latest snapshot and (re)arms the debounce timer. #[derive(Clone)] @@ -59,12 +55,10 @@ pub fn spawn(store: Arc, debounce: Duration) -> Persister { Persister { tx } } -#[allow(dead_code)] -fn _unused(_c: &AtomicUsize) {} - #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; struct CountingStore { diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index cc0aefe..cf47eb6 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; 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; diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 978a81c..60759a6 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -181,7 +181,7 @@ async fn handle_request( exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>, persister: &Persister, ) { - use spacesh_proto::message::{SplitDir, Edge}; + use spacesh_proto::message::SplitDir; use spacesh_proto::layout::{LayoutNode, Orient}; use spacesh_proto::workspace::SurfaceSpec; diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs index 52ce86a..31ca3c4 100644 --- a/crates/spaceshd/src/state_store.rs +++ b/crates/spaceshd/src/state_store.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::Result; use serde::{Deserialize, Serialize}; use spacesh_proto::workspace::{Group, Workspace}; @@ -69,9 +69,6 @@ impl StateStore for JsonStateStore { } } -#[allow(dead_code)] -fn touch(_p: &Path) {} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/spaceshd/src/surface.rs b/crates/spaceshd/src/surface.rs index 0e739f4..b1a9620 100644 --- a/crates/spaceshd/src/surface.rs +++ b/crates/spaceshd/src/surface.rs @@ -41,6 +41,7 @@ pub enum SurfaceMsg { pub struct SurfaceHandle { pub id: SurfaceId, + #[allow(dead_code)] pub workspace_id: WorkspaceId, pub tx: mpsc::Sender, } From c9c3ba1fcdbd5c3fdf2ef74626427ff5421bbe82 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:41:34 +0700 Subject: [PATCH 16/17] feat(app): wire set_group/delete_group bridge commands Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/bridge.rs | 10 ++++++++++ app/src-tauri/src/lib.rs | 2 ++ app/src/socketBridge.ts | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 6621910..02d6de1 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -245,3 +245,13 @@ pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, na pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result { data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?) } + +#[tauri::command] +pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option, color: Option, order: Option) -> Result { + data_of(state.request(Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result { + data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8cd9ffd..d7ff402 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -33,6 +33,8 @@ pub fn run() { bridge::close_workspace, bridge::set_workspace_meta, bridge::create_group, + bridge::set_group, + bridge::delete_group, ]) .run(tauri::generate_context!()) .expect("error while running spacesh"); diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index 28c5062..0abc342 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -130,6 +130,14 @@ export async function createGroup(name: string, color: string): Promise return data.group_id; } +export async function setGroup(groupId: string, meta: { name?: string; color?: string; order?: number }): Promise { + await invoke("set_group", { groupId, name: meta.name ?? null, color: meta.color ?? null, order: meta.order ?? null }); +} + +export async function deleteGroup(groupId: string): Promise { + await invoke("delete_group", { groupId }); +} + export async function closeSurfaceCmd(surfaceId: string): Promise { await invoke("close_surface", { surfaceId }); } From 33fc8625af785377fe70490d52ccfbdb34fc4f9a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:42:01 +0700 Subject: [PATCH 17/17] =?UTF-8?q?docs(plan):=20correct=20M2=20deferral=20n?= =?UTF-8?q?ote=20=E2=80=94=20group=20commands=20wired,=20only=20drag=20UI?= =?UTF-8?q?=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- DOCS/superpowers/plans/2026-06-09-spacesh-m2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md b/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md index e49cd77..adf0124 100644 --- a/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md +++ b/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md @@ -2636,5 +2636,5 @@ git commit -m "feat(app): sidebar, preset picker, wizard, App rewired around wor - **Out of slice:** status rings colored by agent state (M3), auto-unread from events (M3), zoom/search/diff/notifications (M5), remote (M6), auth (separate). The status ring in Sidebar is a static placeholder. - **Two documented partials within M2** (model + protocol fully support both; only the wiring is deferred to keep the slice focused — pick them up if time allows): 1. **Cold-start autostart honoring.** `SurfaceSpec.autostart` is persisted and restored, but the router does not auto-`restart_surface` autostart panels on cold start (default is off and no M2 UI sets it true, so the path is untestable end-to-end this slice). To finish: after `reg.restore(initial)` in `router`, iterate restored surfaces with `spec.autostart == true` and spawn each via `spawn_from_spec` + `spawn_output_bridge` + `set_live`, emitting `surface_restarted`. - 2. **Sidebar drag-reorder UI.** `set_workspace_meta { order, group_id }` and `set_group { order }` exist and are wired in the bridge; the `Sidebar` component renders groups/order but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order. Reordering is fully functional via command today; only the drag affordance is missing. + 2. **Sidebar drag-reorder UI.** The full command surface is wired in the bridge — `set_workspace_meta { order, group_id }`, `set_group { name, color, order }`, `delete_group`, `create_group` — so workspace and group reordering/editing are functional via command today. The deferred piece is only the GUI affordance: the `Sidebar` component renders groups/order/unread/count but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order. ```