feat: persisted per-workspace panel zoom (proto + daemon, auto-clear on removal)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,11 @@ pub enum Cmd {
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
},
|
},
|
||||||
MarkRead { target: MarkReadTarget },
|
MarkRead { target: MarkReadTarget },
|
||||||
|
SetZoom {
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
surface_id: Option<SurfaceId>,
|
||||||
|
},
|
||||||
Health,
|
Health,
|
||||||
Status,
|
Status,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
@@ -322,6 +327,18 @@ mod tests {
|
|||||||
assert_eq!(back, evt);
|
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]
|
#[test]
|
||||||
fn health_cmd_round_trips() {
|
fn health_cmd_round_trips() {
|
||||||
let env = Envelope::Req { id: 1, cmd: Cmd::Health };
|
let env = Envelope::Req { id: 1, cmd: Cmd::Health };
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ pub struct Workspace {
|
|||||||
/// None = empty workspace (no panels yet).
|
/// None = empty workspace (no panels yet).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub layout: Option<LayoutNode>,
|
pub layout: Option<LayoutNode>,
|
||||||
|
/// The single maximized surface for this workspace, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub zoomed: Option<SurfaceId>,
|
||||||
pub surfaces: HashMap<SurfaceId, SurfaceSpec>,
|
pub surfaces: HashMap<SurfaceId, SurfaceSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +69,8 @@ pub struct WorkspaceView {
|
|||||||
pub order: u32,
|
pub order: u32,
|
||||||
pub unread: bool,
|
pub unread: bool,
|
||||||
pub layout: Option<LayoutNode>,
|
pub layout: Option<LayoutNode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub zoomed: Option<SurfaceId>,
|
||||||
pub surfaces: HashMap<SurfaceId, SurfaceView>,
|
pub surfaces: HashMap<SurfaceId, SurfaceView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +104,22 @@ mod tests {
|
|||||||
order: 0,
|
order: 0,
|
||||||
unread: false,
|
unread: false,
|
||||||
layout: None,
|
layout: None,
|
||||||
|
zoomed: None,
|
||||||
surfaces: HashMap::new(),
|
surfaces: HashMap::new(),
|
||||||
};
|
};
|
||||||
let j = serde_json::to_string(&w).unwrap();
|
let j = serde_json::to_string(&w).unwrap();
|
||||||
let back: Workspace = serde_json::from_str(&j).unwrap();
|
let back: Workspace = serde_json::from_str(&j).unwrap();
|
||||||
assert_eq!(back, w);
|
assert_eq!(back, w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_round_trips_with_zoom() {
|
||||||
|
let w = Workspace {
|
||||||
|
id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(),
|
||||||
|
group_id: None, order: 0, unread: false, layout: None,
|
||||||
|
zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(),
|
||||||
|
};
|
||||||
|
let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, w);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl Registry {
|
|||||||
let order = self.workspaces.len() as u32;
|
let order = self.workspaces.len() as u32;
|
||||||
self.workspaces.insert(id.clone(), Workspace {
|
self.workspaces.insert(id.clone(), Workspace {
|
||||||
id: id.clone(), path: key.clone(), name, group_id: None, order,
|
id: id.clone(), path: key.clone(), name, group_id: None, order,
|
||||||
unread: false, layout: None, surfaces: HashMap::new(),
|
unread: false, layout: None, zoomed: None, surfaces: HashMap::new(),
|
||||||
});
|
});
|
||||||
self.by_path.insert(key, id.clone());
|
self.by_path.insert(key, id.clone());
|
||||||
(id, true)
|
(id, true)
|
||||||
@@ -95,6 +95,7 @@ impl Registry {
|
|||||||
if let Some(w) = self.workspaces.get_mut(&ws) {
|
if let Some(w) = self.workspaces.get_mut(&ws) {
|
||||||
w.surfaces.remove(sid);
|
w.surfaces.remove(sid);
|
||||||
w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid));
|
w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid));
|
||||||
|
if w.zoomed.as_ref() == Some(sid) { w.zoomed = None; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +169,7 @@ impl Registry {
|
|||||||
WorkspaceView {
|
WorkspaceView {
|
||||||
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
|
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
|
||||||
group_id: w.group_id.clone(), order: w.order, unread: w.unread,
|
group_id: w.group_id.clone(), order: w.order, unread: w.unread,
|
||||||
layout: w.layout.clone(), surfaces,
|
layout: w.layout.clone(), zoomed: w.zoomed.clone(), surfaces,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn status(&self) -> (Vec<Group>, Vec<WorkspaceView>) {
|
pub fn status(&self) -> (Vec<Group>, Vec<WorkspaceView>) {
|
||||||
@@ -189,6 +190,10 @@ impl Registry {
|
|||||||
self.live.clear();
|
self.live.clear();
|
||||||
self.states.clear();
|
self.states.clear();
|
||||||
for w in state.workspaces {
|
for w in state.workspaces {
|
||||||
|
let mut w = w;
|
||||||
|
if let Some(z) = &w.zoomed {
|
||||||
|
if !w.surfaces.contains_key(z) { w.zoomed = None; }
|
||||||
|
}
|
||||||
self.by_path.insert(w.path.clone(), w.id.clone());
|
self.by_path.insert(w.path.clone(), w.id.clone());
|
||||||
self.workspaces.insert(w.id.clone(), w);
|
self.workspaces.insert(w.id.clone(), w);
|
||||||
}
|
}
|
||||||
@@ -277,6 +282,17 @@ mod tests {
|
|||||||
assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work);
|
assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_surface_clears_zoom() {
|
||||||
|
let mut r = Registry::new();
|
||||||
|
let (ws, _) = r.open_workspace(std::env::temp_dir());
|
||||||
|
let s1 = r.new_surface_id();
|
||||||
|
r.add_surface_spec(&ws, s1.clone(), spec());
|
||||||
|
r.workspace_mut(&ws).unwrap().zoomed = Some(s1.clone());
|
||||||
|
r.remove_surface(&s1);
|
||||||
|
assert!(r.workspace(&ws).unwrap().zoomed.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_state_resets_to_idle() {
|
fn drop_state_resets_to_idle() {
|
||||||
let mut r = Registry::new();
|
let mut r = Registry::new();
|
||||||
|
|||||||
@@ -572,7 +572,12 @@ async fn handle_request(
|
|||||||
crate::hooks::cleanup(&surface_id);
|
crate::hooks::cleanup(&surface_id);
|
||||||
crate::hooks::cleanup_shell(&surface_id);
|
crate::hooks::cleanup_shell(&surface_id);
|
||||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }));
|
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); }
|
if let Some(ws_id) = ws_id {
|
||||||
|
emit_layout(reg, &ws_id, clients);
|
||||||
|
if let Some(view) = reg.workspace_view(&ws_id) {
|
||||||
|
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view }));
|
||||||
|
}
|
||||||
|
}
|
||||||
persister.mark_dirty(reg.persist_state());
|
persister.mark_dirty(reg.persist_state());
|
||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
} else {
|
} else {
|
||||||
@@ -594,6 +599,23 @@ async fn handle_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::SetZoom { workspace_id, surface_id } => {
|
||||||
|
let Some(w) = reg.workspace(&workspace_id) else {
|
||||||
|
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||||
|
};
|
||||||
|
if let Some(sid) = &surface_id {
|
||||||
|
if !w.surfaces.contains_key(sid) {
|
||||||
|
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reg.workspace_mut(&workspace_id).expect("workspace validated above").zoomed = surface_id.clone();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Health => {
|
Cmd::Health => {
|
||||||
let _ = out.send(ok(id, serde_json::json!({
|
let _ = out.send(ok(id, serde_json::json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
@@ -1335,4 +1357,102 @@ mod tests {
|
|||||||
let started = d["started_at_ms"].as_u64().unwrap();
|
let started = d["started_at_ms"].as_u64().unwrap();
|
||||||
assert!(started > 0 && started >= now.saturating_sub(5000) && started <= now + 1000, "started_at_ms plausible: {started} vs now {now}");
|
assert!(started > 0 && started >= now.saturating_sub(5000) && started <= now + 1000, "started_at_ms plausible: {started} vs now {now}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn set_zoom_sets_and_clears_and_autoclears() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
let dir = tempdir_path();
|
||||||
|
let sock = dir.join("sock");
|
||||||
|
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
|
||||||
|
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||||
|
let event_store = make_event_store(&dir);
|
||||||
|
let sock_for_task = sock.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_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::NewSurface {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24,
|
||||||
|
}).await;
|
||||||
|
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let _ = req(&mut s, 3, Cmd::SetZoom {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
surface_id: Some(spacesh_proto::SurfaceId(sid.clone())),
|
||||||
|
}).await;
|
||||||
|
let st = req(&mut s, 4, Cmd::Status).await;
|
||||||
|
let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
|
||||||
|
assert_eq!(w0["zoomed"], sid);
|
||||||
|
|
||||||
|
let _ = req(&mut s, 5, Cmd::SetZoom {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()), surface_id: None,
|
||||||
|
}).await;
|
||||||
|
let st = req(&mut s, 6, Cmd::Status).await;
|
||||||
|
let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
|
||||||
|
assert!(w0["zoomed"].is_null());
|
||||||
|
|
||||||
|
let _ = req(&mut s, 7, Cmd::SetZoom {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
surface_id: Some(spacesh_proto::SurfaceId(sid.clone())),
|
||||||
|
}).await;
|
||||||
|
let _ = req(&mut s, 8, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await;
|
||||||
|
let st = req(&mut s, 9, Cmd::Status).await;
|
||||||
|
let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
|
||||||
|
assert!(w0["zoomed"].is_null(), "closing the zoomed surface clears zoom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn close_zoomed_broadcasts_workspace_changed() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
let dir = tempdir_path();
|
||||||
|
let sock = dir.join("sock");
|
||||||
|
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
|
||||||
|
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
|
||||||
|
let event_store = make_event_store(&dir);
|
||||||
|
let sock_for_task = sock.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
|
||||||
|
wait_for_socket(&sock).await;
|
||||||
|
|
||||||
|
// Control connection: open, spawn, zoom.
|
||||||
|
let mut ctrl = UnixStream::connect(&sock).await.unwrap();
|
||||||
|
let r = req(&mut ctrl, 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 ctrl, 2, Cmd::NewSurface {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24,
|
||||||
|
}).await;
|
||||||
|
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||||
|
let _ = req(&mut ctrl, 3, Cmd::SetZoom {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
surface_id: Some(spacesh_proto::SurfaceId(sid.clone())),
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
// Observer connection: must be attached BEFORE the Close so it catches the broadcast.
|
||||||
|
let mut observer = UnixStream::connect(&sock).await.unwrap();
|
||||||
|
|
||||||
|
// Close the zoomed surface on the control connection.
|
||||||
|
let _ = req(&mut ctrl, 4, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await;
|
||||||
|
|
||||||
|
// Observer must receive a WorkspaceChanged for this workspace with zoomed == None.
|
||||||
|
let mut saw_cleared = false;
|
||||||
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if let Ok(Ok(Some(env))) =
|
||||||
|
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
|
||||||
|
if let Envelope::Evt(Evt::WorkspaceChanged { workspace }) = env {
|
||||||
|
if workspace.id.0 == ws {
|
||||||
|
assert!(workspace.zoomed.is_none(), "WorkspaceChanged must report cleared zoom");
|
||||||
|
saw_cleared = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_cleared, "expected a WorkspaceChanged broadcast with cleared zoom after closing the zoomed surface");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ mod tests {
|
|||||||
order: 0,
|
order: 0,
|
||||||
unread: false,
|
unread: false,
|
||||||
layout: None,
|
layout: None,
|
||||||
|
zoomed: None,
|
||||||
surfaces: std::collections::HashMap::new(),
|
surfaces: std::collections::HashMap::new(),
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user