feat(proto): M2 commands (split/ratios/move/preset/restart/groups/meta) and events

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:13:33 +07:00
parent c8ba401023
commit 2723d40ff9
+129 -1
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; 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. /// Wire envelope. `kind` is the serde tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -26,6 +28,33 @@ pub struct ErrorBody {
pub msg: String, 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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
}
/// Client → daemon commands. The active subset for M0+M1. /// Client → daemon commands. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")] #[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
@@ -50,6 +79,41 @@ pub enum Cmd {
Detach { surface_id: SurfaceId }, Detach { surface_id: SurfaceId },
Focus { surface_id: SurfaceId }, Focus { surface_id: SurfaceId },
Close { surface_id: SurfaceId }, Close { surface_id: SurfaceId },
SplitSurface {
surface_id: SurfaceId,
dir: SplitDir,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
},
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
RestartSurface { surface_id: SurfaceId },
CloseWorkspace { workspace_id: WorkspaceId },
SetWorkspaceMeta {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group_id: Option<Option<GroupId>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
unread: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
},
CreateGroup { name: String, color: String },
SetGroup {
group_id: GroupId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
},
DeleteGroup { group_id: GroupId },
Status, Status,
Shutdown, Shutdown,
} }
@@ -62,6 +126,11 @@ pub enum Evt {
Exit { surface_id: SurfaceId, code: i32 }, Exit { surface_id: SurfaceId, code: i32 },
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId }, SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
SurfaceClosed { surface_id: SurfaceId }, SurfaceClosed { surface_id: SurfaceId },
LayoutChanged { workspace_id: WorkspaceId, layout: Option<LayoutNode> },
WorkspaceChanged { workspace: WorkspaceView },
WorkspaceClosed { workspace_id: WorkspaceId },
GroupsChanged { groups: Vec<Group> },
SurfaceRestarted { surface_id: SurfaceId },
} }
#[cfg(test)] #[cfg(test)]
@@ -112,4 +181,63 @@ mod tests {
_ => panic!("wrong variant"), _ => 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);
}
} }