1use std::sync::Arc;
40
41use agentmux_common::ipc::Event;
42use tokio::sync::{broadcast, Mutex};
43
44use crate::backend::obj::{
45 Block, Client, LayoutActionData, LayoutState as PersistedLayoutState, LeafOrderEntry, Tab,
46 Window, Workspace,
47};
48use crate::backend::storage::store::Store;
49use crate::backend::wcore;
50use crate::state::State;
51
52pub fn spawn_persist_subscriber(
57 events_rx: broadcast::Receiver<Event>,
58 mstore: Arc<Store>,
59 state: Arc<Mutex<State>>,
60) -> tokio::task::JoinHandle<()> {
61 tokio::spawn(run_persist_subscriber(events_rx, mstore, state))
62}
63
64async fn run_persist_subscriber(
65 mut events_rx: broadcast::Receiver<Event>,
66 mstore: Arc<Store>,
67 state: Arc<Mutex<State>>,
68) {
69 tracing::info!(target: "srv-persist-subscriber", "[srv-persist-subscriber] started");
70 loop {
71 match events_rx.recv().await {
72 Ok(event) => {
73 if let Err(e) = apply_event_to_mstore(&event, &mstore) {
74 tracing::warn!(
75 target: "srv-persist-subscriber",
76 "[srv-persist-subscriber] apply failed for event {:?}: {}",
77 event_kind(&event),
78 e
79 );
80 }
81 }
82 Err(broadcast::error::RecvError::Lagged(n)) => {
83 tracing::warn!(
91 target: "srv-persist-subscriber",
92 "[srv-persist-subscriber] dropped {} event(s) — running workspace resync",
93 n
94 );
95 if let Err(e) = resync_workspaces(&state, &mstore).await {
96 tracing::error!(
97 target: "srv-persist-subscriber",
98 "[srv-persist-subscriber] resync failed: {} — SQLite may diverge from reducer until next event",
99 e
100 );
101 }
102 }
103 Err(broadcast::error::RecvError::Closed) => {
104 tracing::info!(target: "srv-persist-subscriber", "[srv-persist-subscriber] bus closed — exiting");
105 return;
106 }
107 }
108 }
109}
110
111async fn resync_workspaces(
139 state: &Arc<Mutex<State>>,
140 mstore: &Store,
141) -> Result<(), Box<dyn std::error::Error>> {
142 let snapshot: Vec<(String, String)> = {
144 let s = state.lock().await;
145 s.workspaces
146 .values()
147 .map(|w| (w.workspace_id.clone(), w.name.clone()))
148 .collect()
149 };
150
151 for (workspace_id, name) in &snapshot {
152 match mstore.get::<Workspace>(workspace_id)? {
153 Some(existing) if existing.name == *name => {
154 }
156 Some(mut existing) => {
157 existing.name = name.clone();
158 mstore.update(&mut existing)?;
159 }
160 None => {
161 let mut ws = Workspace {
162 oid: workspace_id.clone(),
163 name: name.clone(),
164 ..Default::default()
165 };
166 mstore.insert(&mut ws)?;
167 }
168 }
169 }
170 Ok(())
171}
172
173pub(crate) fn apply_event_to_mstore(
187 event: &Event,
188 mstore: &Store,
189) -> Result<(), Box<dyn std::error::Error>> {
190 match event {
191 Event::WorkspaceCreated {
192 workspace_id, name, ..
193 } => apply_workspace_created(mstore, workspace_id, name),
194 Event::WorkspaceDeleted { workspace_id, .. } => {
195 apply_workspace_deleted(mstore, workspace_id)
196 }
197 Event::TabCreated {
198 workspace_id,
199 tab_id,
200 name,
201 ..
202 } => apply_tab_created(mstore, workspace_id, tab_id, name),
203 Event::TabDeleted {
204 workspace_id,
205 tab_id,
206 ..
207 } => apply_tab_deleted(mstore, workspace_id, tab_id),
208 Event::ActiveTabChanged {
209 workspace_id,
210 tab_id,
211 ..
212 } => apply_active_tab_changed(mstore, workspace_id, tab_id.as_deref()),
213 Event::TabReordered {
214 workspace_id,
215 tab_id,
216 new_index,
217 ..
218 } => apply_tab_reordered(mstore, workspace_id, tab_id, *new_index),
219 Event::BlockCreated {
220 tab_id,
221 block_id,
222 meta,
223 ..
224 } => apply_block_created(mstore, tab_id, block_id, meta),
225 Event::BlockDeleted {
226 tab_id, block_id, ..
227 } => apply_block_deleted(mstore, tab_id, block_id),
228 Event::SrvWindowOpened {
229 window_id,
230 workspace_id,
231 ..
232 } => apply_srv_window_opened(mstore, window_id, workspace_id),
233 Event::SrvWindowClosed { window_id, .. } => apply_srv_window_closed(mstore, window_id),
234 Event::SrvWindowWorkspaceChanged {
235 window_id,
236 workspace_id,
237 ..
238 } => apply_srv_window_workspace_changed(mstore, window_id, workspace_id),
239 Event::TabsReorderedBulk {
240 workspace_id,
241 tab_ids,
242 ..
243 } => apply_tabs_reordered_bulk(mstore, workspace_id, tab_ids),
244 Event::WorkspaceRenamed {
245 workspace_id, name, ..
246 } => apply_workspace_renamed(mstore, workspace_id, name),
247 Event::TabRenamed { tab_id, name, .. } => apply_tab_renamed(mstore, tab_id, name),
248 Event::WorkspaceMetaUpdated {
249 workspace_id,
250 meta_patch,
251 ..
252 } => apply_workspace_meta_updated(mstore, workspace_id, meta_patch),
253 Event::WindowMetaUpdated {
254 window_id,
255 meta_patch,
256 ..
257 } => apply_window_meta_updated(mstore, window_id, meta_patch),
258 Event::TabMetaUpdated {
259 tab_id,
260 meta_patch,
261 ..
262 } => apply_tab_meta_updated(mstore, tab_id, meta_patch),
263 Event::BlockMetaUpdated {
264 block_id,
265 meta_patch,
266 ..
267 } => apply_block_meta_updated(mstore, block_id, meta_patch),
268 Event::TabMoved {
269 tab_id,
270 src_workspace_id,
271 dst_workspace_id,
272 dst_index,
273 new_src_active_tab_id,
274 new_dst_active_tab_id,
275 ..
276 } => apply_tab_moved(
277 mstore,
278 tab_id,
279 src_workspace_id,
280 dst_workspace_id,
281 *dst_index,
282 new_src_active_tab_id.as_deref(),
283 new_dst_active_tab_id.as_deref(),
284 ),
285 Event::BlockMoved {
286 block_id,
287 src_tab_id,
288 dst_tab_id,
289 dst_index,
290 ..
291 } => apply_block_moved(mstore, block_id, src_tab_id, dst_tab_id, *dst_index),
292 Event::FocusedNodeChanged { tab_id, node_id, .. } => {
293 apply_focused_node_changed(mstore, tab_id, node_id)
294 }
295 Event::MagnifiedNodeChanged { tab_id, node_id, .. } => {
296 apply_magnified_node_changed(mstore, tab_id, node_id)
297 }
298 Event::LayoutCleared { tab_id, .. } => apply_layout_cleared(mstore, tab_id),
312 Event::LayoutTreeReplaced {
313 tab_id,
314 new_tree,
315 slices,
316 ..
317 } => apply_layout_tree_replaced(mstore, tab_id, new_tree, slices.as_ref()),
318 Event::LayoutNodeDeleted {
329 tab_id,
330 new_tree,
331 tree_cleared,
332 ..
333 } => match (new_tree.is_some(), tree_cleared) {
334 (true, _) => apply_layout_tree_replaced(mstore, tab_id, new_tree, None),
335 (false, true) => apply_layout_tree_replaced(mstore, tab_id, &None, None),
336 (false, false) => Ok(()),
337 },
338 Event::LayoutNodeMoved { tab_id, new_tree, .. }
345 | Event::LayoutNodesSwapped { tab_id, new_tree, .. }
346 | Event::LayoutNodesResized { tab_id, new_tree, .. }
347 | Event::LayoutNodeReplaced { tab_id, new_tree, .. }
348 | Event::LayoutSplitHorizontalApplied { tab_id, new_tree, .. }
349 | Event::LayoutSplitVerticalApplied { tab_id, new_tree, .. }
350 | Event::LayoutNodeInsertedAtIndex { tab_id, new_tree, .. } => {
351 match new_tree {
358 Some(_) => apply_layout_tree_replaced(mstore, tab_id, new_tree, None),
360 None => Ok(()),
361 }
362 }
363 Event::LayoutBackendActionsQueued { tab_id, actions, .. } => {
371 apply_layout_backend_actions_queued(mstore, tab_id, actions)
372 }
373 _ => Ok(()),
376 }
377}
378
379fn apply_workspace_created(
380 mstore: &Store,
381 workspace_id: &str,
382 name: &str,
383) -> Result<(), Box<dyn std::error::Error>> {
384 if mstore.get::<Workspace>(workspace_id)?.is_some() {
385 return Ok(()); }
387 let mut ws = Workspace {
388 oid: workspace_id.to_string(),
389 name: name.to_string(),
390 ..Default::default()
391 };
392 mstore.insert(&mut ws)?;
393 Ok(())
394}
395
396fn apply_workspace_deleted(
397 mstore: &Store,
398 workspace_id: &str,
399) -> Result<(), Box<dyn std::error::Error>> {
400 if mstore.get::<Workspace>(workspace_id)?.is_none() {
401 return Ok(()); }
403 wcore::delete_workspace(mstore, workspace_id)?;
404 Ok(())
405}
406
407fn apply_tab_created(
408 mstore: &Store,
409 workspace_id: &str,
410 tab_id: &str,
411 name: &str,
412) -> Result<(), Box<dyn std::error::Error>> {
413 mstore.with_tx(|tx| {
418 if tx.get::<Tab>(tab_id)?.is_none() {
419 let mut layout = PersistedLayoutState {
425 oid: uuid::Uuid::new_v4().to_string(),
426 rootnode: None,
427 magnifiednodeid: String::new(),
428 focusednodeid: String::new(),
429 leaforder: None,
430 pendingbackendactions: None,
431 meta: None,
432 ..Default::default()
433 };
434 tx.insert(&mut layout)?;
435 let mut tab = Tab {
436 oid: tab_id.to_string(),
437 name: name.to_string(),
438 layoutstate: layout.oid.clone(),
439 ..Default::default()
440 };
441 tx.insert(&mut tab)?;
442 }
443 if let Some(mut ws) = tx.get::<Workspace>(workspace_id)? {
445 let already_present = ws.tabids.iter().any(|t| t == tab_id)
446 || ws.pinnedtabids.iter().any(|t| t == tab_id);
447 if !already_present {
448 ws.tabids.push(tab_id.to_string());
449 tx.update(&mut ws)?;
450 }
451 }
452 Ok(())
453 })?;
454 Ok(())
455}
456
457fn apply_tab_deleted(
458 mstore: &Store,
459 workspace_id: &str,
460 tab_id: &str,
461) -> Result<(), Box<dyn std::error::Error>> {
462 if mstore.get::<Tab>(tab_id)?.is_none() {
463 return Ok(()); }
465 match wcore::delete_tab(mstore, workspace_id, tab_id) {
470 Ok(()) => Ok(()),
471 Err(crate::backend::storage::StoreError::NotFound) => Ok(()),
472 Err(e) => Err(Box::new(e)),
473 }
474}
475
476fn apply_active_tab_changed(
477 mstore: &Store,
478 workspace_id: &str,
479 tab_id: Option<&str>,
480) -> Result<(), Box<dyn std::error::Error>> {
481 let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
482 return Ok(()); };
484 let new = tab_id.unwrap_or("").to_string();
485 if ws.activetabid == new {
486 return Ok(());
487 }
488 ws.activetabid = new;
489 mstore.update(&mut ws)?;
490 Ok(())
491}
492
493fn apply_tab_reordered(
494 mstore: &Store,
495 workspace_id: &str,
496 tab_id: &str,
497 new_index: u32,
498) -> Result<(), Box<dyn std::error::Error>> {
499 let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
500 return Ok(());
501 };
502 let target_index = new_index as usize;
509 if let Some(current_pos) = ws.tabids.iter().position(|t| t == tab_id) {
510 let len = ws.tabids.len();
511 let target = target_index.min(len.saturating_sub(1));
512 if current_pos == target {
513 return Ok(());
514 }
515 let id = ws.tabids.remove(current_pos);
516 ws.tabids.insert(target, id);
517 mstore.update(&mut ws)?;
518 } else if let Some(current_pos) = ws.pinnedtabids.iter().position(|t| t == tab_id) {
519 let len = ws.pinnedtabids.len();
520 let target = target_index.min(len.saturating_sub(1));
521 if current_pos == target {
522 return Ok(());
523 }
524 let id = ws.pinnedtabids.remove(current_pos);
525 ws.pinnedtabids.insert(target, id);
526 mstore.update(&mut ws)?;
527 }
528 Ok(())
530}
531
532fn apply_block_created(
533 mstore: &Store,
534 tab_id: &str,
535 block_id: &str,
536 meta: &serde_json::Value,
537) -> Result<(), Box<dyn std::error::Error>> {
538 let meta_map: crate::backend::obj::MetaMapType = match meta {
540 serde_json::Value::Object(_) => {
541 serde_json::from_value(meta.clone()).unwrap_or_default()
542 }
543 _ => Default::default(),
544 };
545 mstore.with_tx(|tx| {
546 if tx.get::<Block>(block_id)?.is_none() {
547 let mut block = Block {
553 oid: block_id.to_string(),
554 parentoref: format!("tab:{}", tab_id),
555 meta: meta_map,
556 ..Default::default()
557 };
558 tx.insert(&mut block)?;
559 }
560 if let Some(mut tab) = tx.get::<Tab>(tab_id)? {
561 if !tab.blockids.iter().any(|b| b == block_id) {
562 tab.blockids.push(block_id.to_string());
563 tx.update(&mut tab)?;
564 }
565 }
566 Ok(())
567 })?;
568 Ok(())
569}
570
571fn apply_block_deleted(
572 mstore: &Store,
573 tab_id: &str,
574 block_id: &str,
575) -> Result<(), Box<dyn std::error::Error>> {
576 if mstore.get::<Block>(block_id)?.is_none() {
577 return Ok(()); }
579 match wcore::delete_block(mstore, tab_id, block_id) {
580 Ok(()) => Ok(()),
581 Err(crate::backend::storage::StoreError::NotFound) => Ok(()),
582 Err(e) => Err(Box::new(e)),
583 }
584}
585
586fn apply_srv_window_opened(
596 mstore: &Store,
597 window_id: &str,
598 workspace_id: &str,
599) -> Result<(), Box<dyn std::error::Error>> {
600 mstore.with_tx(|tx| {
605 let was_new = match tx.get::<Window>(window_id)? {
606 Some(existing) if existing.workspaceid == workspace_id => false,
607 Some(mut existing) => {
608 existing.workspaceid = workspace_id.to_string();
609 tx.update(&mut existing)?;
610 false
611 }
612 None => {
613 let mut window = Window {
614 oid: window_id.to_string(),
615 workspaceid: workspace_id.to_string(),
616 ..Default::default()
617 };
618 tx.insert(&mut window)?;
619 true
620 }
621 };
622 if was_new {
623 let clients = tx.get_all::<Client>()?;
626 if let Some(mut client) = clients.into_iter().next() {
627 if !client.windowids.iter().any(|id| id == window_id) {
628 client.windowids.push(window_id.to_string());
629 tx.update(&mut client)?;
630 }
631 }
632 }
633 Ok(())
634 })?;
635 Ok(())
636}
637
638pub(crate) fn apply_srv_window_closed(
650 mstore: &Store,
651 window_id: &str,
652) -> Result<(), Box<dyn std::error::Error>> {
653 mstore.with_tx(|tx| {
658 let window_exists = tx.get::<Window>(window_id)?.is_some();
659 let clients = tx.get_all::<Client>()?;
662 if let Some(mut client) = clients.into_iter().next() {
663 if client.windowids.iter().any(|id| id == window_id) {
664 client.windowids.retain(|id| id != window_id);
665 tx.update(&mut client)?;
666 }
667 }
668 if window_exists {
669 tx.delete::<Window>(window_id)?;
670 }
671 Ok(())
672 })?;
673 Ok(())
674}
675
676fn apply_srv_window_workspace_changed(
680 mstore: &Store,
681 window_id: &str,
682 workspace_id: &str,
683) -> Result<(), Box<dyn std::error::Error>> {
684 apply_srv_window_opened(mstore, window_id, workspace_id)
685}
686
687fn apply_tabs_reordered_bulk(
695 mstore: &Store,
696 workspace_id: &str,
697 tab_ids: &[String],
698) -> Result<(), Box<dyn std::error::Error>> {
699 let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
700 return Ok(());
701 };
702 let tabids_match = ws.tabids == tab_ids;
703 let pinned_clear = ws.pinnedtabids.is_empty();
704 if tabids_match && pinned_clear {
705 return Ok(());
706 }
707 ws.tabids = tab_ids.to_vec();
708 ws.pinnedtabids.clear();
709 mstore.update(&mut ws)?;
710 Ok(())
711}
712
713fn apply_workspace_renamed(
715 mstore: &Store,
716 workspace_id: &str,
717 name: &str,
718) -> Result<(), Box<dyn std::error::Error>> {
719 let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
720 return Ok(());
721 };
722 if ws.name == name {
723 return Ok(());
724 }
725 ws.name = name.to_string();
726 mstore.update(&mut ws)?;
727 Ok(())
728}
729
730fn apply_tab_renamed(
732 mstore: &Store,
733 tab_id: &str,
734 name: &str,
735) -> Result<(), Box<dyn std::error::Error>> {
736 let Some(mut tab) = mstore.get::<Tab>(tab_id)? else {
737 return Ok(());
738 };
739 if tab.name == name {
740 return Ok(());
741 }
742 tab.name = name.to_string();
743 mstore.update(&mut tab)?;
744 Ok(())
745}
746
747fn apply_focused_node_changed(
755 mstore: &Store,
756 tab_id: &str,
757 node_id: &str,
758) -> Result<(), Box<dyn std::error::Error>> {
759 let Some(tab) = mstore.get::<Tab>(tab_id)? else {
760 return Ok(());
761 };
762 if tab.layoutstate.is_empty() {
763 return Ok(());
764 }
765 let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
766 return Ok(());
767 };
768 if layout.focusednodeid == node_id {
769 return Ok(());
770 }
771 layout.focusednodeid = node_id.to_string();
772 mstore.update(&mut layout)?;
773 Ok(())
774}
775
776fn apply_magnified_node_changed(
780 mstore: &Store,
781 tab_id: &str,
782 node_id: &str,
783) -> Result<(), Box<dyn std::error::Error>> {
784 let Some(tab) = mstore.get::<Tab>(tab_id)? else {
785 return Ok(());
786 };
787 if tab.layoutstate.is_empty() {
788 return Ok(());
789 }
790 let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
791 return Ok(());
792 };
793 if layout.magnifiednodeid == node_id {
794 return Ok(());
795 }
796 layout.magnifiednodeid = node_id.to_string();
797 mstore.update(&mut layout)?;
798 Ok(())
799}
800
801fn apply_layout_cleared(
809 mstore: &Store,
810 tab_id: &str,
811) -> Result<(), Box<dyn std::error::Error>> {
812 let Some(tab) = mstore.get::<Tab>(tab_id)? else {
813 return Ok(());
814 };
815 if tab.layoutstate.is_empty() {
816 return Ok(());
817 }
818 let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
819 return Ok(());
820 };
821 if layout.rootnode.is_none()
822 && layout.focusednodeid.is_empty()
823 && layout.magnifiednodeid.is_empty()
824 && layout.leaforder.is_none()
825 {
826 return Ok(());
827 }
828 layout.rootnode = None;
829 layout.focusednodeid = String::new();
830 layout.magnifiednodeid = String::new();
831 layout.leaforder = None;
832 mstore.update(&mut layout)?;
833 Ok(())
834}
835
836fn apply_layout_tree_replaced(
855 mstore: &Store,
856 tab_id: &str,
857 new_tree: &Option<agentmux_common::LayoutNode>,
858 slices: Option<&agentmux_common::LayoutClientSlices>,
859) -> Result<(), Box<dyn std::error::Error>> {
860 let Some(tab) = mstore.get::<Tab>(tab_id)? else {
861 return Ok(());
862 };
863 if tab.layoutstate.is_empty() {
864 return Ok(());
865 }
866 let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
867 return Ok(());
868 };
869 let clears = new_tree.is_none();
870
871 let new_focused = match (slices, clears) {
876 (_, true) => String::new(),
877 (Some(s), false) => s.focused_node_id.clone(),
878 (None, false) => layout.focusednodeid.clone(),
879 };
880 let new_magnified = match (slices, clears) {
881 (_, true) => String::new(),
882 (Some(s), false) => s.magnified_node_id.clone(),
883 (None, false) => layout.magnifiednodeid.clone(),
884 };
885 let new_leaforder: Option<Vec<LeafOrderEntry>> = match (slices, clears) {
886 (_, true) => None,
887 (Some(s), false) => match &s.leaforder {
888 Some(v) if !v.is_null() => Some(serde_json::from_value(v.clone())?),
889 _ => None,
890 },
891 (None, false) => layout.leaforder.clone(),
892 };
893 let new_pending: Option<Vec<LayoutActionData>> = match slices {
894 Some(s) => match &s.pending_backend_actions {
898 Some(v) if !v.is_null() => Some(serde_json::from_value(v.clone())?),
899 _ => None,
900 },
901 None => layout.pendingbackendactions.clone(),
902 };
903
904 let already = layout.rootnode == *new_tree
905 && layout.focusednodeid == new_focused
906 && layout.magnifiednodeid == new_magnified
907 && layout.leaforder == new_leaforder
908 && layout.pendingbackendactions == new_pending;
909 if already {
910 return Ok(());
911 }
912 layout.rootnode = new_tree.clone();
913 layout.focusednodeid = new_focused;
914 layout.magnifiednodeid = new_magnified;
915 layout.leaforder = new_leaforder;
916 layout.pendingbackendactions = new_pending;
917 mstore.update(&mut layout)?;
918 Ok(())
919}
920
921fn apply_layout_backend_actions_queued(
935 mstore: &Store,
936 tab_id: &str,
937 actions: &serde_json::Value,
938) -> Result<(), Box<dyn std::error::Error>> {
939 let Some(tab) = mstore.get::<Tab>(tab_id)? else {
940 return Ok(());
941 };
942 if tab.layoutstate.is_empty() {
943 return Ok(());
944 }
945 let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
946 return Ok(());
947 };
948 let incoming: Vec<LayoutActionData> = serde_json::from_value(actions.clone())?;
949 let mut pending = layout.pendingbackendactions.take().unwrap_or_default();
950 let before = pending.len();
951 for action in incoming {
952 if !pending.iter().any(|p| p.actionid == action.actionid) {
953 pending.push(action);
954 }
955 }
956 if pending.len() == before {
957 layout.pendingbackendactions = Some(pending);
959 return Ok(());
960 }
961 layout.pendingbackendactions = Some(pending);
962 mstore.update(&mut layout)?;
963 Ok(())
964}
965
966fn apply_workspace_meta_updated(
972 mstore: &Store,
973 workspace_id: &str,
974 meta_patch: &serde_json::Value,
975) -> Result<(), Box<dyn std::error::Error>> {
976 let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
977 return Ok(());
978 };
979 if merge_meta_patch(&mut ws.meta, meta_patch) {
980 mstore.update(&mut ws)?;
981 }
982 Ok(())
983}
984
985fn apply_window_meta_updated(
990 mstore: &Store,
991 window_id: &str,
992 meta_patch: &serde_json::Value,
993) -> Result<(), Box<dyn std::error::Error>> {
994 let Some(mut window) = mstore.get::<Window>(window_id)? else {
995 return Ok(());
996 };
997 if merge_meta_patch(&mut window.meta, meta_patch) {
998 mstore.update(&mut window)?;
999 }
1000 Ok(())
1001}
1002
1003fn apply_tab_meta_updated(
1005 mstore: &Store,
1006 tab_id: &str,
1007 meta_patch: &serde_json::Value,
1008) -> Result<(), Box<dyn std::error::Error>> {
1009 let Some(mut tab) = mstore.get::<Tab>(tab_id)? else {
1010 return Ok(());
1011 };
1012 if merge_meta_patch(&mut tab.meta, meta_patch) {
1013 mstore.update(&mut tab)?;
1014 }
1015 Ok(())
1016}
1017
1018fn apply_block_meta_updated(
1020 mstore: &Store,
1021 block_id: &str,
1022 meta_patch: &serde_json::Value,
1023) -> Result<(), Box<dyn std::error::Error>> {
1024 let Some(mut block) = mstore.get::<Block>(block_id)? else {
1025 return Ok(());
1026 };
1027 if merge_meta_patch(&mut block.meta, meta_patch) {
1028 mstore.update(&mut block)?;
1029 }
1030 Ok(())
1031}
1032
1033fn apply_tab_moved(
1048 mstore: &Store,
1049 tab_id: &str,
1050 src_workspace_id: &str,
1051 dst_workspace_id: &str,
1052 dst_index: u32,
1053 new_src_active_tab_id: Option<&str>,
1054 new_dst_active_tab_id: Option<&str>,
1055) -> Result<(), Box<dyn std::error::Error>> {
1056 mstore.with_tx(|tx| {
1060 if let Some(mut src_ws) = tx.get::<Workspace>(src_workspace_id)? {
1062 let len_before_tabids = src_ws.tabids.len();
1063 let len_before_pinned = src_ws.pinnedtabids.len();
1064 src_ws.tabids.retain(|id| id != tab_id);
1065 src_ws.pinnedtabids.retain(|id| id != tab_id);
1066 let new_active = new_src_active_tab_id.unwrap_or("").to_string();
1067 let active_changed = src_ws.activetabid != new_active;
1068 if src_ws.tabids.len() != len_before_tabids
1069 || src_ws.pinnedtabids.len() != len_before_pinned
1070 || active_changed
1071 {
1072 src_ws.activetabid = new_active;
1073 tx.update(&mut src_ws)?;
1074 }
1075 }
1076
1077 if let Some(mut dst_ws) = tx.get::<Workspace>(dst_workspace_id)? {
1082 let mut changed = false;
1083 if !dst_ws.tabids.iter().any(|id| id == tab_id) {
1084 let clamped = (dst_index as usize).min(dst_ws.tabids.len());
1085 dst_ws.tabids.insert(clamped, tab_id.to_string());
1086 changed = true;
1087 }
1088 if let Some(new_active) = new_dst_active_tab_id {
1089 if dst_ws.activetabid != new_active {
1090 dst_ws.activetabid = new_active.to_string();
1091 changed = true;
1092 }
1093 }
1094 if changed {
1095 tx.update(&mut dst_ws)?;
1096 }
1097 }
1098
1099 Ok(())
1102 })?;
1103 Ok(())
1104}
1105
1106fn apply_block_moved(
1110 mstore: &Store,
1111 block_id: &str,
1112 src_tab_id: &str,
1113 dst_tab_id: &str,
1114 dst_index: u32,
1115) -> Result<(), Box<dyn std::error::Error>> {
1116 mstore.with_tx(|tx| {
1121 if src_tab_id == dst_tab_id {
1122 if let Some(mut tab) = tx.get::<Tab>(src_tab_id)? {
1124 tab.blockids.retain(|id| id != block_id);
1125 let clamped = (dst_index as usize).min(tab.blockids.len());
1126 tab.blockids.insert(clamped, block_id.to_string());
1127 tx.update(&mut tab)?;
1128 }
1129 return Ok(());
1130 }
1131
1132 if let Some(mut src_tab) = tx.get::<Tab>(src_tab_id)? {
1134 let before = src_tab.blockids.len();
1135 src_tab.blockids.retain(|id| id != block_id);
1136 if src_tab.blockids.len() != before {
1137 tx.update(&mut src_tab)?;
1138 }
1139 }
1140
1141 if let Some(mut dst_tab) = tx.get::<Tab>(dst_tab_id)? {
1143 if !dst_tab.blockids.iter().any(|id| id == block_id) {
1144 let clamped = (dst_index as usize).min(dst_tab.blockids.len());
1145 dst_tab.blockids.insert(clamped, block_id.to_string());
1146 tx.update(&mut dst_tab)?;
1147 }
1148 }
1149
1150 if let Some(mut block) = tx.get::<Block>(block_id)? {
1152 let new_parent = format!("tab:{}", dst_tab_id);
1153 if block.parentoref != new_parent {
1154 block.parentoref = new_parent;
1155 tx.update(&mut block)?;
1156 }
1157 }
1158
1159 Ok(())
1160 })?;
1161 Ok(())
1162}
1163
1164fn merge_meta_patch(
1175 meta: &mut crate::backend::obj::MetaMapType,
1176 patch: &serde_json::Value,
1177) -> bool {
1178 let serde_json::Value::Object(patch_map) = patch else {
1179 return false;
1180 };
1181 let mut changed = false;
1182 for (k, v) in patch_map {
1184 if !k.ends_with(":*") {
1185 continue;
1186 }
1187 if !matches!(v, serde_json::Value::Bool(true)) {
1188 continue;
1189 }
1190 let prefix = k.trim_end_matches(":*");
1191 if prefix.is_empty() {
1192 continue;
1193 }
1194 let prefix_colon = format!("{prefix}:");
1195 let before = meta.len();
1196 meta.retain(|k2, _| k2 != prefix && !k2.starts_with(&prefix_colon));
1197 if meta.len() != before {
1198 changed = true;
1199 }
1200 }
1201 for (k, v) in patch_map {
1203 if k.ends_with(":*") {
1204 continue;
1205 }
1206 if v.is_null() {
1207 if meta.remove(k).is_some() {
1208 changed = true;
1209 }
1210 continue;
1211 }
1212 match meta.get(k) {
1213 Some(existing) if existing == v => {}
1214 _ => {
1215 meta.insert(k.clone(), v.clone());
1216 changed = true;
1217 }
1218 }
1219 }
1220 changed
1221}
1222
1223fn event_kind(event: &Event) -> &'static str {
1225 match event {
1226 Event::WorkspaceCreated { .. } => "WorkspaceCreated",
1227 Event::WorkspaceDeleted { .. } => "WorkspaceDeleted",
1228 Event::TabCreated { .. } => "TabCreated",
1229 Event::TabDeleted { .. } => "TabDeleted",
1230 Event::ActiveTabChanged { .. } => "ActiveTabChanged",
1231 Event::TabReordered { .. } => "TabReordered",
1232 Event::SrvWindowOpened { .. } => "SrvWindowOpened",
1233 Event::SrvWindowClosed { .. } => "SrvWindowClosed",
1234 Event::SrvWindowWorkspaceChanged { .. } => "SrvWindowWorkspaceChanged",
1235 Event::TabsReorderedBulk { .. } => "TabsReorderedBulk",
1236 Event::WorkspaceRenamed { .. } => "WorkspaceRenamed",
1237 Event::TabRenamed { .. } => "TabRenamed",
1238 Event::WorkspaceMetaUpdated { .. } => "WorkspaceMetaUpdated",
1239 Event::WindowMetaUpdated { .. } => "WindowMetaUpdated",
1240 Event::TabMetaUpdated { .. } => "TabMetaUpdated",
1241 Event::BlockMetaUpdated { .. } => "BlockMetaUpdated",
1242 Event::TabMoved { .. } => "TabMoved",
1243 Event::BlockMoved { .. } => "BlockMoved",
1244 Event::BlockCreated { .. } => "BlockCreated",
1245 Event::BlockDeleted { .. } => "BlockDeleted",
1246 Event::FocusedNodeChanged { .. } => "FocusedNodeChanged",
1247 Event::MagnifiedNodeChanged { .. } => "MagnifiedNodeChanged",
1248 _ => "Other",
1249 }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use crate::backend::storage::store::Store;
1256
1257 fn store() -> Arc<Store> {
1258 let store = Store::open_in_memory().expect("in-memory mstore");
1260 Arc::new(store)
1261 }
1262
1263 #[test]
1264 fn workspace_created_inserts_row() {
1265 let s = store();
1266 apply_event_to_mstore(
1267 &Event::WorkspaceCreated {
1268 workspace_id: "ws-1".into(),
1269 name: "Alpha".into(),
1270 version: 1,
1271 },
1272 &s,
1273 )
1274 .unwrap();
1275 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1276 assert_eq!(ws.name, "Alpha");
1277 }
1278
1279 #[test]
1280 fn workspace_created_idempotent_on_duplicate() {
1281 let s = store();
1282 let ev = Event::WorkspaceCreated {
1283 workspace_id: "ws-1".into(),
1284 name: "Alpha".into(),
1285 version: 1,
1286 };
1287 apply_event_to_mstore(&ev, &s).unwrap();
1288 apply_event_to_mstore(&ev, &s).unwrap();
1290 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1292 assert_eq!(ws.name, "Alpha");
1293 }
1294
1295 #[test]
1296 fn workspace_deleted_silent_when_missing() {
1297 let s = store();
1298 apply_event_to_mstore(
1299 &Event::WorkspaceDeleted {
1300 workspace_id: "ghost".into(),
1301 block_ids: Vec::new(),
1302 version: 1,
1303 },
1304 &s,
1305 )
1306 .unwrap();
1307 }
1308
1309 #[test]
1310 fn tab_created_links_into_workspace() {
1311 let s = store();
1312 apply_event_to_mstore(
1313 &Event::WorkspaceCreated {
1314 workspace_id: "ws-1".into(),
1315 name: "Alpha".into(),
1316 version: 1,
1317 },
1318 &s,
1319 )
1320 .unwrap();
1321 apply_event_to_mstore(
1322 &Event::TabCreated {
1323 workspace_id: "ws-1".into(),
1324 tab_id: "tab-1".into(),
1325 name: "Tab".into(),
1326 version: 2,
1327 },
1328 &s,
1329 )
1330 .unwrap();
1331 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1332 assert_eq!(ws.tabids, vec!["tab-1".to_string()]);
1333 let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
1334 assert_eq!(tab.name, "Tab");
1335 }
1336
1337 #[test]
1338 fn tab_created_idempotent_on_duplicate_link() {
1339 let s = store();
1340 apply_event_to_mstore(
1341 &Event::WorkspaceCreated {
1342 workspace_id: "ws-1".into(),
1343 name: "Alpha".into(),
1344 version: 1,
1345 },
1346 &s,
1347 )
1348 .unwrap();
1349 let ev = Event::TabCreated {
1350 workspace_id: "ws-1".into(),
1351 tab_id: "tab-1".into(),
1352 name: "Tab".into(),
1353 version: 2,
1354 };
1355 apply_event_to_mstore(&ev, &s).unwrap();
1356 apply_event_to_mstore(&ev, &s).unwrap();
1357 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1358 assert_eq!(ws.tabids.len(), 1);
1359 }
1360
1361 #[test]
1362 fn active_tab_changed_updates_workspace() {
1363 let s = store();
1364 apply_event_to_mstore(
1365 &Event::WorkspaceCreated {
1366 workspace_id: "ws-1".into(),
1367 name: "Alpha".into(),
1368 version: 1,
1369 },
1370 &s,
1371 )
1372 .unwrap();
1373 apply_event_to_mstore(
1374 &Event::TabCreated {
1375 workspace_id: "ws-1".into(),
1376 tab_id: "tab-1".into(),
1377 name: "Tab".into(),
1378 version: 2,
1379 },
1380 &s,
1381 )
1382 .unwrap();
1383 apply_event_to_mstore(
1384 &Event::ActiveTabChanged {
1385 workspace_id: "ws-1".into(),
1386 tab_id: Some("tab-1".into()),
1387 version: 3,
1388 },
1389 &s,
1390 )
1391 .unwrap();
1392 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1393 assert_eq!(ws.activetabid, "tab-1");
1394 }
1395
1396 #[test]
1397 fn active_tab_changed_to_none_clears_activetabid() {
1398 let s = store();
1399 apply_event_to_mstore(
1400 &Event::WorkspaceCreated {
1401 workspace_id: "ws-1".into(),
1402 name: "Alpha".into(),
1403 version: 1,
1404 },
1405 &s,
1406 )
1407 .unwrap();
1408 apply_event_to_mstore(
1409 &Event::TabCreated {
1410 workspace_id: "ws-1".into(),
1411 tab_id: "tab-1".into(),
1412 name: "Tab".into(),
1413 version: 2,
1414 },
1415 &s,
1416 )
1417 .unwrap();
1418 apply_event_to_mstore(
1419 &Event::ActiveTabChanged {
1420 workspace_id: "ws-1".into(),
1421 tab_id: Some("tab-1".into()),
1422 version: 3,
1423 },
1424 &s,
1425 )
1426 .unwrap();
1427 apply_event_to_mstore(
1428 &Event::ActiveTabChanged {
1429 workspace_id: "ws-1".into(),
1430 tab_id: None,
1431 version: 4,
1432 },
1433 &s,
1434 )
1435 .unwrap();
1436 let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1437 assert_eq!(ws.activetabid, "");
1438 }
1439
1440 #[test]
1441 fn tab_created_provisions_layoutstate() {
1442 let s = store();
1443 apply_event_to_mstore(
1444 &Event::WorkspaceCreated {
1445 workspace_id: "ws-1".into(),
1446 name: "Alpha".into(),
1447 version: 1,
1448 },
1449 &s,
1450 )
1451 .unwrap();
1452 apply_event_to_mstore(
1453 &Event::TabCreated {
1454 workspace_id: "ws-1".into(),
1455 tab_id: "tab-1".into(),
1456 name: "Tab".into(),
1457 version: 2,
1458 },
1459 &s,
1460 )
1461 .unwrap();
1462 let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
1463 assert!(!tab.layoutstate.is_empty());
1466 let layout = s
1467 .get::<PersistedLayoutState>(&tab.layoutstate)
1468 .unwrap()
1469 .expect("layout row must exist");
1470 assert_eq!(layout.oid, tab.layoutstate);
1471 }
1472
1473 fn leaf(id: &str, block: &str) -> agentmux_common::LayoutNode {
1475 agentmux_common::LayoutNode {
1476 id: id.into(),
1477 data: Some(agentmux_common::LayoutNodeData {
1478 block_id: block.into(),
1479 ..Default::default()
1480 }),
1481 ..Default::default()
1482 }
1483 }
1484
1485 fn ws_tab(s: &Store) -> String {
1488 apply_event_to_mstore(
1489 &Event::WorkspaceCreated {
1490 workspace_id: "ws-1".into(),
1491 name: "A".into(),
1492 version: 1,
1493 },
1494 s,
1495 )
1496 .unwrap();
1497 apply_event_to_mstore(
1498 &Event::TabCreated {
1499 workspace_id: "ws-1".into(),
1500 tab_id: "tab-1".into(),
1501 name: "T".into(),
1502 version: 2,
1503 },
1504 s,
1505 )
1506 .unwrap();
1507 "tab-1".to_string()
1508 }
1509
1510 fn layout_of(s: &Store, tab_id: &str) -> PersistedLayoutState {
1511 let tab = s.get::<Tab>(tab_id).unwrap().unwrap();
1512 s.get::<PersistedLayoutState>(&tab.layoutstate)
1513 .unwrap()
1514 .unwrap()
1515 }
1516
1517 #[test]
1518 fn layout_tree_replaced_persists_rootnode() {
1519 let s = store();
1520 let tab = ws_tab(&s);
1521 apply_event_to_mstore(
1522 &Event::LayoutTreeReplaced {
1523 tab_id: tab.clone(),
1524 new_tree: Some(leaf("n1", "b1")),
1525 correlation_id: "c1".into(),
1526 slices: None,
1527 version: 3,
1528 },
1529 &s,
1530 )
1531 .unwrap();
1532 let root = layout_of(&s, &tab).rootnode.expect("rootnode set");
1533 assert_eq!(root.id, "n1");
1534 assert_eq!(root.data.unwrap().block_id, "b1");
1535 }
1536
1537 #[test]
1538 fn layout_granular_event_persists_new_tree() {
1539 let s = store();
1544 let tab = ws_tab(&s);
1545 apply_event_to_mstore(
1546 &Event::LayoutNodeMoved {
1547 tab_id: tab.clone(),
1548 new_tree: Some(leaf("moved-root", "bm")),
1549 node_id: "x".into(),
1550 new_parent_id: "y".into(),
1551 index: 0,
1552 correlation_id: "c".into(),
1553 version: 3,
1554 },
1555 &s,
1556 )
1557 .unwrap();
1558 let root = layout_of(&s, &tab)
1559 .rootnode
1560 .expect("granular event persisted rootnode");
1561 assert_eq!(root.id, "moved-root");
1562 }
1563
1564 #[test]
1565 fn layout_granular_event_with_none_tree_does_not_clear() {
1566 let s = store();
1570 let tab = ws_tab(&s);
1571 apply_event_to_mstore(
1572 &Event::LayoutTreeReplaced {
1573 tab_id: tab.clone(),
1574 new_tree: Some(leaf("keep", "bk")),
1575 correlation_id: "seed".into(),
1576 slices: None,
1577 version: 2,
1578 },
1579 &s,
1580 )
1581 .unwrap();
1582 apply_event_to_mstore(
1583 &Event::LayoutNodeMoved {
1584 tab_id: tab.clone(),
1585 new_tree: None, node_id: "x".into(),
1587 new_parent_id: "y".into(),
1588 index: 0,
1589 correlation_id: "c".into(),
1590 version: 3,
1591 },
1592 &s,
1593 )
1594 .unwrap();
1595 let root = layout_of(&s, &tab)
1596 .rootnode
1597 .expect("tree preserved, not cleared by a None granular event");
1598 assert_eq!(root.id, "keep");
1599 }
1600
1601 #[test]
1604 fn layout_node_deleted_persists_new_tree() {
1605 let s = store();
1606 let tab = ws_tab(&s);
1607 apply_event_to_mstore(
1608 &Event::LayoutNodeDeleted {
1609 tab_id: tab.clone(),
1610 node_id: "gone".into(),
1611 new_tree: Some(leaf("survivor", "bs")),
1612 tree_cleared: false,
1613 was_focused: false,
1614 was_magnified: false,
1615 correlation_id: "c".into(),
1616 version: 3,
1617 },
1618 &s,
1619 )
1620 .unwrap();
1621 let root = layout_of(&s, &tab)
1622 .rootnode
1623 .expect("delete event persisted the resulting tree");
1624 assert_eq!(root.id, "survivor");
1625 }
1626
1627 #[test]
1628 fn layout_node_deleted_none_without_cleared_is_ignored() {
1629 let s = store();
1633 let tab = ws_tab(&s);
1634 apply_event_to_mstore(
1635 &Event::LayoutTreeReplaced {
1636 tab_id: tab.clone(),
1637 new_tree: Some(leaf("keep", "bk")),
1638 correlation_id: "seed".into(),
1639 slices: None,
1640 version: 2,
1641 },
1642 &s,
1643 )
1644 .unwrap();
1645 apply_event_to_mstore(
1646 &Event::LayoutNodeDeleted {
1647 tab_id: tab.clone(),
1648 node_id: "x".into(),
1649 new_tree: None,
1650 tree_cleared: false, was_focused: false,
1652 was_magnified: false,
1653 correlation_id: "c".into(),
1654 version: 3,
1655 },
1656 &s,
1657 )
1658 .unwrap();
1659 let root = layout_of(&s, &tab)
1660 .rootnode
1661 .expect("tree preserved — skewed delete event must not clear");
1662 assert_eq!(root.id, "keep");
1663 }
1664
1665 #[test]
1666 fn layout_node_deleted_tree_cleared_erases_tree() {
1667 let s = store();
1670 let tab = ws_tab(&s);
1671 apply_event_to_mstore(
1672 &Event::LayoutTreeReplaced {
1673 tab_id: tab.clone(),
1674 new_tree: Some(leaf("doomed-root", "bd")),
1675 correlation_id: "seed".into(),
1676 slices: None,
1677 version: 2,
1678 },
1679 &s,
1680 )
1681 .unwrap();
1682 apply_event_to_mstore(
1683 &Event::LayoutNodeDeleted {
1684 tab_id: tab.clone(),
1685 node_id: "doomed-root".into(),
1686 new_tree: None,
1687 tree_cleared: true,
1688 was_focused: true,
1689 was_magnified: false,
1690 correlation_id: "c".into(),
1691 version: 3,
1692 },
1693 &s,
1694 )
1695 .unwrap();
1696 assert!(
1697 layout_of(&s, &tab).rootnode.is_none(),
1698 "tree_cleared delete must wipe the persisted tree"
1699 );
1700 }
1701
1702 #[test]
1703 fn layout_cleared_wipes_tree_and_ids() {
1704 let s = store();
1705 let tab = ws_tab(&s);
1706 apply_event_to_mstore(
1707 &Event::LayoutTreeReplaced {
1708 tab_id: tab.clone(),
1709 new_tree: Some(leaf("n1", "b1")),
1710 correlation_id: "c".into(),
1711 slices: None,
1712 version: 3,
1713 },
1714 &s,
1715 )
1716 .unwrap();
1717 apply_event_to_mstore(
1718 &Event::FocusedNodeChanged {
1719 tab_id: tab.clone(),
1720 node_id: "n1".into(),
1721 version: 4,
1722 },
1723 &s,
1724 )
1725 .unwrap();
1726 apply_event_to_mstore(
1727 &Event::MagnifiedNodeChanged {
1728 tab_id: tab.clone(),
1729 node_id: "n1".into(),
1730 version: 5,
1731 },
1732 &s,
1733 )
1734 .unwrap();
1735 apply_event_to_mstore(
1736 &Event::LayoutCleared {
1737 tab_id: tab.clone(),
1738 correlation_id: "c".into(),
1739 version: 6,
1740 },
1741 &s,
1742 )
1743 .unwrap();
1744 let layout = layout_of(&s, &tab);
1745 assert!(layout.rootnode.is_none());
1746 assert!(layout.focusednodeid.is_empty());
1747 assert!(layout.magnifiednodeid.is_empty());
1748 assert!(layout.leaforder.is_none());
1749 }
1750
1751 #[test]
1752 fn layout_tree_replaced_empty_clears_focus_magnify() {
1753 let s = store();
1754 let tab = ws_tab(&s);
1755 apply_event_to_mstore(
1756 &Event::LayoutTreeReplaced {
1757 tab_id: tab.clone(),
1758 new_tree: Some(leaf("n1", "b1")),
1759 correlation_id: "c".into(),
1760 slices: None,
1761 version: 3,
1762 },
1763 &s,
1764 )
1765 .unwrap();
1766 apply_event_to_mstore(
1767 &Event::FocusedNodeChanged {
1768 tab_id: tab.clone(),
1769 node_id: "n1".into(),
1770 version: 4,
1771 },
1772 &s,
1773 )
1774 .unwrap();
1775 apply_event_to_mstore(
1776 &Event::LayoutTreeReplaced {
1777 tab_id: tab.clone(),
1778 new_tree: None,
1779 correlation_id: "c".into(),
1780 slices: None,
1781 version: 5,
1782 },
1783 &s,
1784 )
1785 .unwrap();
1786 let layout = layout_of(&s, &tab);
1787 assert!(layout.rootnode.is_none());
1788 assert!(layout.focusednodeid.is_empty());
1789 assert!(layout.magnifiednodeid.is_empty());
1790 }
1791
1792 #[test]
1793 fn layout_tree_replaced_idempotent_no_version_churn() {
1794 let s = store();
1795 let tab = ws_tab(&s);
1796 let ev = Event::LayoutTreeReplaced {
1797 tab_id: tab.clone(),
1798 new_tree: Some(leaf("n1", "b1")),
1799 correlation_id: "c".into(),
1800 slices: None,
1801 version: 3,
1802 };
1803 apply_event_to_mstore(&ev, &s).unwrap();
1804 let v1 = layout_of(&s, &tab).version;
1805 apply_event_to_mstore(&ev, &s).unwrap();
1806 let v2 = layout_of(&s, &tab).version;
1807 assert_eq!(v1, v2, "idempotent re-apply must not bump version");
1808 }
1809
1810 fn slices(
1813 leaforder: Option<serde_json::Value>,
1814 focused: &str,
1815 magnified: &str,
1816 pending: Option<serde_json::Value>,
1817 ) -> agentmux_common::LayoutClientSlices {
1818 agentmux_common::LayoutClientSlices {
1819 leaforder,
1820 focused_node_id: focused.into(),
1821 magnified_node_id: magnified.into(),
1822 pending_backend_actions: pending,
1823 }
1824 }
1825
1826 #[test]
1827 fn layout_tree_replaced_with_slices_writes_full_row() {
1828 let s = store();
1829 let tab = ws_tab(&s);
1830 apply_event_to_mstore(
1831 &Event::LayoutTreeReplaced {
1832 tab_id: tab.clone(),
1833 new_tree: Some(leaf("n1", "b1")),
1834 correlation_id: "c".into(),
1835 slices: Some(slices(
1836 Some(serde_json::json!([{ "nodeid": "n1", "blockid": "b1" }])),
1837 "n1",
1838 "n1",
1839 None,
1840 )),
1841 version: 3,
1842 },
1843 &s,
1844 )
1845 .unwrap();
1846 let layout = layout_of(&s, &tab);
1847 assert_eq!(layout.rootnode.unwrap().id, "n1");
1848 assert_eq!(layout.focusednodeid, "n1");
1849 assert_eq!(layout.magnifiednodeid, "n1");
1850 let lo = layout.leaforder.expect("leaforder written");
1851 assert_eq!(lo.len(), 1);
1852 assert_eq!(lo[0].nodeid, "n1");
1853 assert_eq!(lo[0].blockid, "b1");
1854 }
1855
1856 #[test]
1857 fn layout_tree_replaced_slices_ack_clears_pending_actions() {
1858 let s = store();
1863 let tab = ws_tab(&s);
1864 {
1866 let t = s.get::<Tab>(&tab).unwrap().unwrap();
1867 let mut layout = s
1868 .get::<PersistedLayoutState>(&t.layoutstate)
1869 .unwrap()
1870 .unwrap();
1871 layout.pendingbackendactions = Some(vec![LayoutActionData {
1872 actiontype: "insert".into(),
1873 actionid: "a1".into(),
1874 blockid: "b-new".into(),
1875 nodesize: None,
1876 nodesizefraction: None,
1877 indexarr: None,
1878 focused: true,
1879 magnified: false,
1880 ephemeral: false,
1881 targetblockid: String::new(),
1882 position: String::new(),
1883 }]);
1884 s.update(&mut layout).unwrap();
1885 }
1886 apply_event_to_mstore(
1887 &Event::LayoutTreeReplaced {
1888 tab_id: tab.clone(),
1889 new_tree: Some(leaf("n1", "b1")),
1890 correlation_id: "c".into(),
1891 slices: Some(slices(None, "n1", "", None)),
1892 version: 4,
1893 },
1894 &s,
1895 )
1896 .unwrap();
1897 assert!(
1898 layout_of(&s, &tab).pendingbackendactions.is_none(),
1899 "slice write with absent pending queue must clear the column (ack path)"
1900 );
1901 }
1902
1903 #[test]
1904 fn layout_tree_replaced_without_slices_leaves_client_columns_untouched() {
1905 let s = store();
1909 let tab = ws_tab(&s);
1910 apply_event_to_mstore(
1911 &Event::LayoutTreeReplaced {
1912 tab_id: tab.clone(),
1913 new_tree: Some(leaf("n1", "b1")),
1914 correlation_id: "seed".into(),
1915 slices: Some(slices(
1916 Some(serde_json::json!([{ "nodeid": "n1", "blockid": "b1" }])),
1917 "n1",
1918 "",
1919 Some(serde_json::json!([{
1920 "actiontype": "insert",
1921 "actionid": "a1",
1922 "blockid": "b2",
1923 "focused": false,
1924 "magnified": false,
1925 "ephemeral": false,
1926 }])),
1927 )),
1928 version: 3,
1929 },
1930 &s,
1931 )
1932 .unwrap();
1933 apply_event_to_mstore(
1935 &Event::LayoutTreeReplaced {
1936 tab_id: tab.clone(),
1937 new_tree: Some(leaf("n2", "b2")),
1938 correlation_id: "c".into(),
1939 slices: None,
1940 version: 4,
1941 },
1942 &s,
1943 )
1944 .unwrap();
1945 let layout = layout_of(&s, &tab);
1946 assert_eq!(layout.rootnode.unwrap().id, "n2", "tree replaced");
1947 assert_eq!(layout.focusednodeid, "n1", "focus untouched");
1948 assert!(layout.leaforder.is_some(), "leaforder untouched");
1949 assert!(
1950 layout.pendingbackendactions.is_some(),
1951 "pending queue untouched by tree-only write"
1952 );
1953 }
1954
1955 #[test]
1956 fn layout_events_silent_when_tab_missing() {
1957 let s = store();
1958 apply_event_to_mstore(
1959 &Event::LayoutCleared {
1960 tab_id: "ghost".into(),
1961 correlation_id: "c".into(),
1962 version: 1,
1963 },
1964 &s,
1965 )
1966 .unwrap();
1967 apply_event_to_mstore(
1968 &Event::LayoutTreeReplaced {
1969 tab_id: "ghost".into(),
1970 new_tree: Some(leaf("n", "b")),
1971 correlation_id: "c".into(),
1972 slices: None,
1973 version: 1,
1974 },
1975 &s,
1976 )
1977 .unwrap();
1978 }
1979
1980 #[test]
1981 fn workspace_deleted_cascades_pinned_tabs() {
1982 let s = store();
1983 let ws = wcore::create_workspace(&s, "Alpha").unwrap();
1986 let pinned_tab =
1987 wcore::create_tab_with_opts(&s, &ws.oid, "PinnedTab", true).unwrap();
1988 let ws_loaded = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
1990 assert!(ws_loaded.pinnedtabids.contains(&pinned_tab.oid));
1991 apply_event_to_mstore(
1993 &Event::WorkspaceDeleted {
1994 workspace_id: ws.oid.clone(),
1995 block_ids: Vec::new(),
1996 version: 1,
1997 },
1998 &s,
1999 )
2000 .unwrap();
2001 assert!(s.get::<Tab>(&pinned_tab.oid).unwrap().is_none());
2004 assert!(s.get::<Workspace>(&ws.oid).unwrap().is_none());
2005 }
2006
2007 #[test]
2008 fn block_created_links_into_tab() {
2009 let s = store();
2010 apply_event_to_mstore(
2011 &Event::WorkspaceCreated {
2012 workspace_id: "ws-1".into(),
2013 name: "Alpha".into(),
2014 version: 1,
2015 },
2016 &s,
2017 )
2018 .unwrap();
2019 apply_event_to_mstore(
2020 &Event::TabCreated {
2021 workspace_id: "ws-1".into(),
2022 tab_id: "tab-1".into(),
2023 name: "Tab".into(),
2024 version: 2,
2025 },
2026 &s,
2027 )
2028 .unwrap();
2029 apply_event_to_mstore(
2030 &Event::BlockCreated { tab_id: "tab-1".into(), block_id: "block-1".into(), meta: serde_json::Value::Null, version: 3 },
2031 &s,
2032 )
2033 .unwrap();
2034 let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2035 assert_eq!(tab.blockids, vec!["block-1".to_string()]);
2036 let block = s.get::<Block>("block-1").unwrap().unwrap();
2037 assert_eq!(block.parentoref, "tab:tab-1");
2038 }
2039
2040 #[test]
2041 fn block_deleted_unlinks_from_tab() {
2042 let s = store();
2043 apply_event_to_mstore(
2044 &Event::WorkspaceCreated {
2045 workspace_id: "ws-1".into(),
2046 name: "Alpha".into(),
2047 version: 1,
2048 },
2049 &s,
2050 )
2051 .unwrap();
2052 apply_event_to_mstore(
2053 &Event::TabCreated {
2054 workspace_id: "ws-1".into(),
2055 tab_id: "tab-1".into(),
2056 name: "Tab".into(),
2057 version: 2,
2058 },
2059 &s,
2060 )
2061 .unwrap();
2062 apply_event_to_mstore(
2063 &Event::BlockCreated { tab_id: "tab-1".into(), block_id: "block-1".into(), meta: serde_json::Value::Null, version: 3 },
2064 &s,
2065 )
2066 .unwrap();
2067 apply_event_to_mstore(
2068 &Event::BlockDeleted {
2069 tab_id: "tab-1".into(),
2070 block_id: "block-1".into(),
2071 version: 4,
2072 },
2073 &s,
2074 )
2075 .unwrap();
2076 assert!(s.get::<Block>("block-1").unwrap().is_none());
2077 let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2078 assert!(tab.blockids.is_empty());
2079 }
2080
2081 #[test]
2088 fn tabs_reordered_bulk_drains_legacy_pinned_tabids() {
2089 let s = store();
2090 let ws = wcore::create_workspace(&s, "Alpha").unwrap();
2091 let pinned_tab =
2092 wcore::create_tab_with_opts(&s, &ws.oid, "Pinned", true).unwrap();
2093 let regular_tab =
2094 wcore::create_tab_with_opts(&s, &ws.oid, "Regular", false).unwrap();
2095 let ws_before = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
2097 assert!(ws_before.pinnedtabids.contains(&pinned_tab.oid));
2098
2099 apply_event_to_mstore(
2102 &Event::TabsReorderedBulk {
2103 workspace_id: ws.oid.clone(),
2104 tab_ids: vec![pinned_tab.oid.clone(), regular_tab.oid.clone()],
2105 version: ws_before.version as u64 + 1,
2106 },
2107 &s,
2108 )
2109 .unwrap();
2110 let ws_after = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
2111 assert_eq!(
2112 ws_after.tabids,
2113 vec![pinned_tab.oid.clone(), regular_tab.oid.clone()]
2114 );
2115 assert!(
2116 ws_after.pinnedtabids.is_empty(),
2117 "pinnedtabids must be drained, was {:?}",
2118 ws_after.pinnedtabids
2119 );
2120 }
2121
2122 #[test]
2126 fn meta_updated_clears_section_prefix() {
2127 let s = store();
2128 apply_event_to_mstore(
2129 &Event::TabCreated {
2130 workspace_id: "ws-1".into(),
2131 tab_id: "tab-1".into(),
2132 name: "Tab".into(),
2133 version: 1,
2134 },
2135 &s,
2136 )
2137 .unwrap();
2138 apply_event_to_mstore(
2139 &Event::WorkspaceCreated {
2140 workspace_id: "ws-1".into(),
2141 name: "Alpha".into(),
2142 version: 1,
2143 },
2144 &s,
2145 )
2146 .unwrap();
2147 let mut tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2149 tab.meta
2150 .insert("term:fontsize".into(), serde_json::json!(14));
2151 tab.meta
2152 .insert("term:theme".into(), serde_json::json!("solarized"));
2153 tab.meta.insert("name".into(), serde_json::json!("keep"));
2154 s.update(&mut tab).unwrap();
2155 apply_event_to_mstore(
2157 &Event::TabMetaUpdated {
2158 tab_id: "tab-1".into(),
2159 meta_patch: serde_json::json!({
2160 "term:*": true,
2161 "term:fontsize": 18,
2162 }),
2163 version: 2,
2164 },
2165 &s,
2166 )
2167 .unwrap();
2168 let after = s.get::<Tab>("tab-1").unwrap().unwrap();
2169 assert!(!after.meta.contains_key("term:theme"),
2170 "term:theme should be cleared by `term:*` patch");
2171 assert_eq!(
2172 after.meta.get("term:fontsize"),
2173 Some(&serde_json::json!(18)),
2174 "term:fontsize replacement must take effect after the section clear"
2175 );
2176 assert_eq!(after.meta.get("name"), Some(&serde_json::json!("keep")));
2177 }
2178
2179 #[test]
2182 fn tab_moved_cross_workspace_rewrites_both_tabids() {
2183 let s = store();
2184 for (id, name) in &[("src-ws", "Src"), ("dst-ws", "Dst")] {
2186 apply_event_to_mstore(
2187 &Event::WorkspaceCreated {
2188 workspace_id: id.to_string(),
2189 name: name.to_string(),
2190 version: 1,
2191 },
2192 &s,
2193 )
2194 .unwrap();
2195 }
2196 apply_event_to_mstore(
2198 &Event::TabCreated {
2199 workspace_id: "src-ws".into(),
2200 tab_id: "tab-1".into(),
2201 name: "T".into(),
2202 version: 2,
2203 },
2204 &s,
2205 )
2206 .unwrap();
2207 let src = s.get::<Workspace>("src-ws").unwrap().unwrap();
2208 assert_eq!(src.tabids, vec!["tab-1".to_string()]);
2209
2210 apply_event_to_mstore(
2213 &Event::TabMoved {
2214 tab_id: "tab-1".into(),
2215 src_workspace_id: "src-ws".into(),
2216 dst_workspace_id: "dst-ws".into(),
2217 dst_index: 0,
2218 new_src_active_tab_id: None,
2219 new_dst_active_tab_id: Some("tab-1".into()),
2220 version: 3,
2221 },
2222 &s,
2223 )
2224 .unwrap();
2225 let src = s.get::<Workspace>("src-ws").unwrap().unwrap();
2226 let dst = s.get::<Workspace>("dst-ws").unwrap().unwrap();
2227 assert!(src.tabids.is_empty());
2228 assert_eq!(src.activetabid, "");
2229 assert_eq!(dst.tabids, vec!["tab-1".to_string()]);
2230 assert_eq!(dst.activetabid, "tab-1", "dst active should be the moved tab");
2231 }
2232
2233 #[test]
2234 fn tab_moved_idempotent_on_re_delivery() {
2235 let s = store();
2236 for (id, name) in &[("src-ws", "Src"), ("dst-ws", "Dst")] {
2237 apply_event_to_mstore(
2238 &Event::WorkspaceCreated {
2239 workspace_id: id.to_string(),
2240 name: name.to_string(),
2241 version: 1,
2242 },
2243 &s,
2244 )
2245 .unwrap();
2246 }
2247 apply_event_to_mstore(
2248 &Event::TabCreated {
2249 workspace_id: "src-ws".into(),
2250 tab_id: "tab-1".into(),
2251 name: "T".into(),
2252 version: 2,
2253 },
2254 &s,
2255 )
2256 .unwrap();
2257 let ev = Event::TabMoved {
2258 tab_id: "tab-1".into(),
2259 src_workspace_id: "src-ws".into(),
2260 dst_workspace_id: "dst-ws".into(),
2261 dst_index: 0,
2262 new_src_active_tab_id: None,
2263 new_dst_active_tab_id: Some("tab-1".into()),
2264 version: 3,
2265 };
2266 apply_event_to_mstore(&ev, &s).unwrap();
2267 apply_event_to_mstore(&ev, &s).unwrap();
2269 let dst = s.get::<Workspace>("dst-ws").unwrap().unwrap();
2270 assert_eq!(dst.tabids, vec!["tab-1".to_string()]);
2272 }
2273
2274 #[test]
2275 fn block_moved_cross_tab_updates_block_lists_and_parent() {
2276 let s = store();
2277 let ws = wcore::create_workspace(&s, "W").unwrap();
2278 let src_tab = wcore::create_tab_with_opts(&s, &ws.oid, "src", false).unwrap();
2279 let dst_tab = wcore::create_tab_with_opts(&s, &ws.oid, "dst", false).unwrap();
2280 apply_event_to_mstore(
2282 &Event::BlockCreated {
2283 tab_id: src_tab.oid.clone(),
2284 block_id: "blk-1".into(),
2285 meta: serde_json::Value::Null,
2286 version: 1,
2287 },
2288 &s,
2289 )
2290 .unwrap();
2291 let src_before = s.get::<Tab>(&src_tab.oid).unwrap().unwrap();
2292 assert_eq!(src_before.blockids, vec!["blk-1".to_string()]);
2293
2294 apply_event_to_mstore(
2295 &Event::BlockMoved {
2296 block_id: "blk-1".into(),
2297 src_tab_id: src_tab.oid.clone(),
2298 dst_tab_id: dst_tab.oid.clone(),
2299 dst_index: 0,
2300 version: 2,
2301 },
2302 &s,
2303 )
2304 .unwrap();
2305 let src_after = s.get::<Tab>(&src_tab.oid).unwrap().unwrap();
2306 let dst_after = s.get::<Tab>(&dst_tab.oid).unwrap().unwrap();
2307 let block = s.get::<Block>("blk-1").unwrap().unwrap();
2308 assert!(src_after.blockids.is_empty());
2309 assert_eq!(dst_after.blockids, vec!["blk-1".to_string()]);
2310 assert_eq!(block.parentoref, format!("tab:{}", dst_tab.oid));
2311 }
2312
2313 #[test]
2314 fn block_moved_intra_tab_repositions() {
2315 let s = store();
2316 let ws = wcore::create_workspace(&s, "W").unwrap();
2317 let tab = wcore::create_tab_with_opts(&s, &ws.oid, "t", false).unwrap();
2318 for id in &["b1", "b2", "b3"] {
2319 apply_event_to_mstore(
2320 &Event::BlockCreated {
2321 tab_id: tab.oid.clone(),
2322 block_id: id.to_string(),
2323 meta: serde_json::Value::Null,
2324 version: 1,
2325 },
2326 &s,
2327 )
2328 .unwrap();
2329 }
2330 apply_event_to_mstore(
2332 &Event::BlockMoved {
2333 block_id: "b1".into(),
2334 src_tab_id: tab.oid.clone(),
2335 dst_tab_id: tab.oid.clone(),
2336 dst_index: 2,
2337 version: 2,
2338 },
2339 &s,
2340 )
2341 .unwrap();
2342 let tab_after = s.get::<Tab>(&tab.oid).unwrap().unwrap();
2343 assert_eq!(tab_after.blockids, vec!["b2".to_string(), "b3".to_string(), "b1".to_string()]);
2344 }
2345
2346 fn action(id: &str, block: &str) -> LayoutActionData {
2349 LayoutActionData {
2350 actiontype: "insert".into(),
2351 actionid: id.into(),
2352 blockid: block.into(),
2353 nodesize: None,
2354 nodesizefraction: None,
2355 indexarr: None,
2356 focused: true,
2357 magnified: false,
2358 ephemeral: false,
2359 targetblockid: String::new(),
2360 position: String::new(),
2361 }
2362 }
2363
2364 #[test]
2365 fn layout_backend_actions_queued_appends_not_replaces() {
2366 let s = store();
2367 let tab = ws_tab(&s);
2368 apply_event_to_mstore(
2369 &Event::LayoutBackendActionsQueued {
2370 tab_id: tab.clone(),
2371 actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2372 correlation_id: "c1".into(),
2373 version: 3,
2374 },
2375 &s,
2376 )
2377 .unwrap();
2378 apply_event_to_mstore(
2379 &Event::LayoutBackendActionsQueued {
2380 tab_id: tab.clone(),
2381 actions: serde_json::to_value(vec![action("a2", "b2")]).unwrap(),
2382 correlation_id: "c2".into(),
2383 version: 4,
2384 },
2385 &s,
2386 )
2387 .unwrap();
2388 let pending = layout_of(&s, &tab).pendingbackendactions.unwrap();
2389 assert_eq!(pending.len(), 2, "second event must APPEND, not replace");
2390 assert_eq!(pending[0].actionid, "a1");
2391 assert_eq!(pending[1].actionid, "a2");
2392 }
2393
2394 #[test]
2395 fn layout_backend_actions_queued_dedupes_on_double_apply() {
2396 let s = store();
2399 let tab = ws_tab(&s);
2400 let ev = Event::LayoutBackendActionsQueued {
2401 tab_id: tab.clone(),
2402 actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2403 correlation_id: "c1".into(),
2404 version: 3,
2405 };
2406 apply_event_to_mstore(&ev, &s).unwrap();
2407 apply_event_to_mstore(&ev, &s).unwrap();
2408 let pending = layout_of(&s, &tab).pendingbackendactions.unwrap();
2409 assert_eq!(pending.len(), 1, "double-apply must not duplicate by actionid");
2410 }
2411
2412 #[test]
2413 fn layout_backend_actions_queued_silent_when_tab_missing() {
2414 let s = store();
2415 apply_event_to_mstore(
2416 &Event::LayoutBackendActionsQueued {
2417 tab_id: "ghost".into(),
2418 actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2419 correlation_id: "c1".into(),
2420 version: 1,
2421 },
2422 &s,
2423 )
2424 .unwrap();
2425 }
2426}