Files
spaceshell/crates/spacesh-proto/src/message.rs
T
vasyansk f9a565a712 feat(app): clear all events from the Event Center (red trash icon)
Adds Cmd::ClearEvents + Evt::EventsCleared: the daemon drops the persistent
event log (keeping next_id monotonic), persists, and broadcasts so every
client empties its list. A red trash icon next to 'Mark all read' triggers it;
disabled when the list is empty. Threaded through proto, the daemon handler,
the Tauri bridge, and socketBridge. Includes an EventLog::clear test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:38:35 +07:00

392 lines
14 KiB
Rust

use serde::{Deserialize, Serialize};
use crate::event::{EventRecord, MarkReadTarget};
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
use crate::layout::LayoutNode;
use crate::status::SurfaceState;
use crate::workspace::{Group, WorkspaceView};
/// Wire envelope. `kind` is the serde tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Envelope {
Req {
id: u64,
cmd: Cmd,
},
Res {
id: u64,
ok: bool,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<ErrorBody>,
},
Evt(Evt),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: 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.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
pub enum Cmd {
Open { path: String },
NewSurface {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
cols: u16,
rows: u16,
},
Input {
surface_id: SurfaceId,
/// base64-encoded keyboard bytes.
bytes: String,
},
Resize { surface_id: SurfaceId, cols: u16, rows: u16 },
Attach { surface_id: SurfaceId },
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<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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pinned: Option<bool>,
},
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 },
SetState { surface_id: SurfaceId, state: SurfaceState },
EventLog {
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
MarkRead { target: MarkReadTarget },
ClearEvents,
SetZoom {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
surface_id: Option<SurfaceId>,
},
Health,
Status,
Shutdown,
GetConfig,
SetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
default_shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_size: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accent: Option<String>,
},
}
/// Daemon → subscribers push events. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "evt", content = "data", rename_all = "snake_case")]
pub enum Evt {
Output { surface_id: SurfaceId, bytes: Vec<u8> },
Exit { surface_id: SurfaceId, code: i32 },
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
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 },
State { surface_id: SurfaceId, state: SurfaceState },
Event { record: EventRecord },
EventsRead { ids: Vec<u64> },
EventsCleared,
ConfigChanged { config: crate::config_view::ConfigView },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{SurfaceId, WorkspaceId};
#[test]
fn req_round_trips_through_json() {
let env = Envelope::Req {
id: 42,
cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) },
};
let json = serde_json::to_string(&env).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(env, back);
}
#[test]
fn res_ok_and_err_serialize_distinctly() {
let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None };
let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null,
error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) };
assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true"));
assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND"));
}
#[test]
fn evt_output_carries_workspace_scoped_surface() {
let evt = Envelope::Evt(Evt::Output {
surface_id: SurfaceId("s_1".into()),
bytes: vec![104, 105],
});
let json = serde_json::to_string(&evt).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(evt, back);
}
#[test]
fn new_surface_defaults_cmd_to_none() {
let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#;
let env: Envelope = serde_json::from_str(json).unwrap();
match env {
Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => {
assert!(command.is_none());
assert!(args.is_empty());
}
_ => 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);
}
#[test]
fn set_state_round_trips() {
let env = Envelope::Req {
id: 1,
cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done },
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn state_event_round_trips() {
let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait });
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"state""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn event_log_cmd_round_trips() {
let env = Envelope::Req { id: 1, cmd: Cmd::EventLog { limit: Some(50) } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_round_trips() {
let env = Envelope::Req {
id: 2,
cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::All },
};
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"mark_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn event_evt_round_trips() {
let evt = Envelope::Evt(Evt::Event {
record: crate::event::EventRecord {
id: 3,
surface_id: SurfaceId("s_1".into()),
workspace_id: WorkspaceId("w_1".into()),
workspace_name: "p".into(),
agent_label: None,
kind: crate::event::EventKind::Done,
ts: 1,
read: false,
},
});
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"event""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn events_read_evt_round_trips() {
let evt = Envelope::Evt(Evt::EventsRead { ids: vec![1, 2, 3] });
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"events_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn set_zoom_cmd_round_trips() {
let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom {
workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } };
let j = serde_json::to_string(&z).unwrap();
assert!(j.contains(r#""cmd":"set_zoom""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), z);
let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom {
workspace_id: WorkspaceId("w_1".into()), surface_id: None } };
assert_eq!(serde_json::from_str::<Envelope>(&serde_json::to_string(&unz).unwrap()).unwrap(), unz);
}
#[test]
fn health_cmd_round_trips() {
let env = Envelope::Req { id: 1, cmd: Cmd::Health };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"health""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn event_log_cmd_no_limit_round_trips() {
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
assert!(j.contains(r#""args":{}"#), "no-limit serializes to empty args, got: {j}");
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_ids_and_surface_round_trip() {
let ids = Envelope::Req { id: 10, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Ids(vec![1, 2]) } };
let j = serde_json::to_string(&ids).unwrap();
assert!(j.contains(r#""target":"ids""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), ids);
let surf = Envelope::Req { id: 11, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Surface(SurfaceId("s_3".into())) } };
let j = serde_json::to_string(&surf).unwrap();
assert!(j.contains(r#""target":"surface""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), surf);
}
}