From 2723d40ff9acc8466062b0c78ce95ab5e4ba48e5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:13:33 +0700 Subject: [PATCH] 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); + } }