1use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9pub(super) fn handle_set_focused_node(state: &mut State, tab_id: String, node_id: String) -> Vec<Event> {
15 let Some(tab) = state.tabs.get_mut(&tab_id) else {
16 let v = state.bump_version();
17 return vec![Event::Error {
18 code: ErrorCode::InvalidCommand,
19 message: format!("SetFocusedNode: unknown tab {}", tab_id),
20 fatal: false,
21 version: v,
22 }];
23 };
24 if tab.focused_node_id == node_id {
25 return Vec::new();
26 }
27 tab.focused_node_id = node_id.clone();
28 let v = state.bump_version();
29 vec![Event::FocusedNodeChanged {
30 tab_id,
31 node_id,
32 version: v,
33 }]
34}
35
36pub(super) fn handle_set_magnified_node(state: &mut State, tab_id: String, node_id: String) -> Vec<Event> {
40 let Some(tab) = state.tabs.get_mut(&tab_id) else {
41 let v = state.bump_version();
42 return vec![Event::Error {
43 code: ErrorCode::InvalidCommand,
44 message: format!("SetMagnifiedNode: unknown tab {}", tab_id),
45 fatal: false,
46 version: v,
47 }];
48 };
49 if tab.magnified_node_id == node_id {
50 return Vec::new();
51 }
52 tab.magnified_node_id = node_id.clone();
53 let v = state.bump_version();
54 vec![Event::MagnifiedNodeChanged {
55 tab_id,
56 node_id,
57 version: v,
58 }]
59}
60
61pub(super) fn handle_layout_clear(
62 state: &mut State,
63 tab_id: String,
64 correlation_id: String,
65) -> Vec<Event> {
66 let Some(tab) = state.tabs.get_mut(&tab_id) else {
67 let v = state.bump_version();
68 return vec![Event::Error {
69 code: ErrorCode::InvalidCommand,
70 message: format!("LayoutClear: unknown tab {}", tab_id),
71 fatal: false,
72 version: v,
73 }];
74 };
75 tab.rootnode = None;
76 tab.focused_node_id = String::new();
77 tab.magnified_node_id = String::new();
78 let v = state.bump_version();
79 vec![Event::LayoutCleared {
80 tab_id,
81 correlation_id,
82 version: v,
83 }]
84}
85
86pub(super) fn handle_layout_set_tree(
87 state: &mut State,
88 tab_id: String,
89 new_tree: Option<agentmux_common::LayoutNode>,
90 correlation_id: String,
91 slices: Option<agentmux_common::LayoutClientSlices>,
92) -> Vec<Event> {
93 let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
96 let Some(tab) = state.tabs.get_mut(&tab_id) else {
97 let v = state.bump_version();
98 return vec![Event::Error {
99 code: ErrorCode::InvalidCommand,
100 message: format!("LayoutSetTree: unknown tab {}", tab_id),
101 fatal: false,
102 version: v,
103 }];
104 };
105 tab.rootnode = new_tree.clone();
106 if let Some(s) = &slices {
111 tab.focused_node_id = s.focused_node_id.clone();
112 tab.magnified_node_id = s.magnified_node_id.clone();
113 }
114 if new_tree.is_none() {
118 tab.focused_node_id = String::new();
119 tab.magnified_node_id = String::new();
120 }
121 let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
132 if pruned > 0 {
133 reconcile_focus_magnify(tab);
134 tracing::warn!(
135 tab_id = %tab_id,
136 pruned,
137 "reducer: LayoutSetTree pruned dangling layout leaf/leaves referencing since-deleted blocks"
138 );
139 }
140 let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
144 if snapped > 0 {
145 tracing::warn!(
146 tab_id = %tab_id,
147 snapped,
148 "reducer: LayoutSetTree snapped minimize-locked node size(s) back to their locked values"
149 );
150 }
151 let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
154 if !violations.is_empty() {
155 tracing::error!(
156 tab_id = %tab_id,
157 violations = ?violations,
158 "layout-doctor: invariant violation(s) in tree persisted by LayoutSetTree"
159 );
160 }
161 let new_tree = tab.rootnode.clone();
162 let v = state.bump_version();
163 vec![Event::LayoutTreeReplaced {
164 tab_id,
165 new_tree,
166 correlation_id,
167 slices,
168 version: v,
169 }]
170}
171
172pub(super) fn handle_layout_insert_node(
173 state: &mut State,
174 tab_id: String,
175 node: agentmux_common::LayoutNode,
176 parent_id: Option<String>,
177 index: Option<usize>,
178 focus_after: bool,
179 magnify_after: bool,
180 correlation_id: String,
181) -> Vec<Event> {
182 let Some(tab) = state.tabs.get_mut(&tab_id) else {
183 let v = state.bump_version();
184 return vec![Event::Error {
185 code: ErrorCode::InvalidCommand,
186 message: format!("LayoutInsertNode: unknown tab {}", tab_id),
187 fatal: false,
188 version: v,
189 }];
190 };
191 let empty_tree = tab.rootnode.is_none();
204 if empty_tree {
205 if parent_id.is_some() || index.is_some() {
213 let v = state.bump_version();
214 return vec![Event::Error {
215 code: ErrorCode::InvalidCommand,
216 message: format!(
217 "LayoutInsertNode: empty tree cannot honour explicit parent_id={:?} / index={:?} (tab {})",
218 parent_id, index, tab_id
219 ),
220 fatal: false,
221 version: v,
222 }];
223 }
224 tab.rootnode = Some(node.clone());
225 } else if let Some(pid) = parent_id.as_deref() {
226 let root = tab.rootnode.as_mut().expect("non-empty checked above");
227 match crate::backend::layout::find_node_by_id_mut(root, pid) {
228 Some(parent_node) if crate::backend::layout::is_effectively_minimized(parent_node) => {
233 let v = state.bump_version();
234 return vec![Event::Error {
235 code: ErrorCode::InvalidCommand,
236 message: format!(
237 "LayoutInsertNode: parent_id {:?} is minimize-locked, cannot host inserts (tab {})",
238 pid, tab_id
239 ),
240 fatal: false,
241 version: v,
242 }];
243 }
244 Some(parent_node) if parent_node.data.is_none() => {
245 let len = parent_node.children.len();
246 let target = index.map(|i| i.min(len)).unwrap_or(len);
247 parent_node.children.insert(target, node.clone());
248 }
249 Some(_) => {
250 let v = state.bump_version();
252 return vec![Event::Error {
253 code: ErrorCode::InvalidCommand,
254 message: format!(
255 "LayoutInsertNode: parent_id {:?} is a leaf node, cannot host children (tab {})",
256 pid, tab_id
257 ),
258 fatal: false,
259 version: v,
260 }];
261 }
262 None => {
263 let v = state.bump_version();
264 return vec![Event::Error {
265 code: ErrorCode::InvalidCommand,
266 message: format!(
267 "LayoutInsertNode: parent_id {:?} not found in tree (tab {})",
268 pid, tab_id
269 ),
270 fatal: false,
271 version: v,
272 }];
273 }
274 }
275 } else {
276 let root = tab.rootnode.as_mut().expect("non-empty checked above");
277 crate::backend::layout::insert_node(root, node.clone());
278 }
279 if focus_after || magnify_after {
291 tab.focused_node_id = node.id.clone();
292 }
293 if magnify_after {
294 tab.magnified_node_id = node.id.clone();
295 }
296 let v = state.bump_version();
297 vec![Event::LayoutNodeInserted {
298 tab_id,
299 node,
300 parent_id,
308 index,
309 correlation_id,
310 version: v,
311 }]
312}
313
314pub(super) fn handle_layout_delete_node(
315 state: &mut State,
316 tab_id: String,
317 node_id: String,
318 correlation_id: String,
319) -> Vec<Event> {
320 let Some(tab) = state.tabs.get_mut(&tab_id) else {
321 let v = state.bump_version();
322 return vec![Event::Error {
323 code: ErrorCode::InvalidCommand,
324 message: format!("LayoutDeleteNode: unknown tab {}", tab_id),
325 fatal: false,
326 version: v,
327 }];
328 };
329 let pre_focused = tab.focused_node_id.clone();
332 let pre_magnified = tab.magnified_node_id.clone();
333 let Some(root) = tab.rootnode.as_mut() else {
334 return Vec::new();
336 };
337 if root.id == node_id {
343 tab.rootnode = None;
344 } else if let Err(e) = crate::backend::layout::delete_node(root, &node_id) {
345 let v = state.bump_version();
346 return vec![Event::Error {
347 code: ErrorCode::InvalidCommand,
348 message: format!("LayoutDeleteNode: {} (tab {})", e, tab_id),
349 fatal: false,
350 version: v,
351 }];
352 }
353
354 let (was_focused, was_magnified) = clear_dangling_focus_magnify(tab, &pre_focused, &pre_magnified);
367
368 let new_tree = tab.rootnode.clone();
375 let tree_cleared = new_tree.is_none();
376 let v = state.bump_version();
377 vec![Event::LayoutNodeDeleted {
378 tab_id,
379 node_id,
380 new_tree,
381 tree_cleared,
382 was_focused,
383 was_magnified,
384 correlation_id,
385 version: v,
386 }]
387}
388
389fn clear_dangling_focus_magnify(
396 tab: &mut crate::state::TabRecord,
397 pre_focused: &str,
398 pre_magnified: &str,
399) -> (bool, bool) {
400 let id_resolves = |id: &str| -> bool {
401 if id.is_empty() {
402 return true;
403 }
404 match tab.rootnode.as_ref() {
405 None => false,
406 Some(root) => crate::backend::layout::find_node_by_id(root, id).is_some(),
407 }
408 };
409 let was_focused = !pre_focused.is_empty() && !id_resolves(pre_focused);
410 let was_magnified = !pre_magnified.is_empty() && !id_resolves(pre_magnified);
411 if was_focused {
412 tab.focused_node_id = String::new();
413 }
414 if was_magnified {
415 tab.magnified_node_id = String::new();
416 }
417 (was_focused, was_magnified)
418}
419
420pub(super) fn handle_layout_delete_node_by_block(
430 state: &mut State,
431 tab_id: String,
432 block_id: String,
433 correlation_id: String,
434) -> Vec<Event> {
435 if let Some(root) = state.tabs.get_mut(&tab_id).and_then(|tab| tab.rootnode.as_mut()) {
440 if crate::backend::layout::remove_stack_member(root, &block_id) {
441 let new_tree = state.tabs.get(&tab_id).and_then(|tab| tab.rootnode.clone());
442 let v = state.bump_version();
443 return vec![Event::LayoutTreeReplaced {
444 tab_id,
445 new_tree,
446 correlation_id,
447 slices: None,
450 version: v,
451 }];
452 }
453 }
454 let node_id = match state
455 .tabs
456 .get(&tab_id)
457 .and_then(|tab| tab.rootnode.as_ref())
458 .and_then(|root| crate::backend::layout::find_leaf_containing_block(root, &block_id))
459 .map(|leaf| leaf.id.clone())
460 {
461 Some(id) => id,
462 None => return Vec::new(),
464 };
465 handle_layout_delete_node(state, tab_id, node_id, correlation_id)
466}
467
468pub(super) fn handle_layout_queue_backend_actions(
479 state: &mut State,
480 tab_id: String,
481 actions: serde_json::Value,
482 correlation_id: String,
483) -> Vec<Event> {
484 if !state.tabs.contains_key(&tab_id) {
485 return unknown_tab(state, "LayoutQueueBackendActions", &tab_id);
486 }
487 match actions.as_array() {
488 Some(arr) if !arr.is_empty() => {}
489 _ => {
490 return op_error(
491 state,
492 format!(
493 "LayoutQueueBackendActions: actions must be a non-empty JSON array (tab {})",
494 tab_id
495 ),
496 )
497 }
498 }
499 let v = state.bump_version();
500 vec![Event::LayoutBackendActionsQueued {
501 tab_id,
502 actions,
503 correlation_id,
504 version: v,
505 }]
506}
507
508fn stack_tree_edit(
524 state: &mut State,
525 op: &str,
526 tab_id: String,
527 correlation_id: String,
528 not_found: String,
529 edit: impl FnOnce(&mut agentmux_common::LayoutNode) -> bool,
530) -> Vec<Event> {
531 let applied = match state.tabs.get_mut(&tab_id) {
532 None => return unknown_tab(state, op, &tab_id),
533 Some(tab) => tab.rootnode.as_mut().is_some_and(edit),
534 };
535 if !applied {
536 return op_error(state, format!("{op}: {not_found} (tab {tab_id})"));
537 }
538 let new_tree = state.tabs.get(&tab_id).and_then(|tab| tab.rootnode.clone());
539 let violations = crate::backend::layout::validate_layout_invariants(&new_tree);
540 if !violations.is_empty() {
541 tracing::error!(tab_id = %tab_id, op, violations = ?violations, "layout-doctor: invariant violation(s) after a pane-tab edit");
542 }
543 let v = state.bump_version();
544 vec![Event::LayoutTreeReplaced {
545 tab_id,
546 new_tree,
547 correlation_id,
548 slices: None,
549 version: v,
550 }]
551}
552
553pub(super) fn handle_layout_stack_push(
554 state: &mut State,
555 tab_id: String,
556 target_block_id: String,
557 block_id: String,
558 activate: bool,
559 correlation_id: String,
560) -> Vec<Event> {
561 if state.blocks.get(&block_id).map(|b| b.tab_id.as_str()) != Some(tab_id.as_str()) {
563 return op_error(state, format!("LayoutStackPush: block {block_id} is not in tab {tab_id}"));
564 }
565 let not_found = format!("no pane holds block {target_block_id}");
566 stack_tree_edit(state, "LayoutStackPush", tab_id, correlation_id, not_found, |root| {
567 crate::backend::layout::push_stack_member(root, &target_block_id, &block_id, activate)
568 })
569}
570
571pub(super) fn handle_layout_stack_activate(
572 state: &mut State,
573 tab_id: String,
574 block_id: String,
575 correlation_id: String,
576) -> Vec<Event> {
577 let not_found = format!("no pane holds block {block_id}");
578 stack_tree_edit(state, "LayoutStackActivate", tab_id, correlation_id, not_found, |root| {
579 crate::backend::layout::activate_stack_member(root, &block_id)
580 })
581}
582
583pub(super) fn handle_create_block_in_stack(
587 state: &mut State,
588 tab_id: String,
589 target_block_id: String,
590 meta: serde_json::Value,
591 activate: bool,
592) -> Vec<Event> {
593 let Some(tab) = state.tabs.get(&tab_id) else {
594 return unknown_tab(state, "CreateBlockInStack", &tab_id);
595 };
596 let placeable = tab
597 .rootnode
598 .as_ref()
599 .is_some_and(|root| crate::backend::layout::find_leaf_containing_block(root, &target_block_id).is_some());
600 if !placeable {
601 return op_error(
602 state,
603 format!("CreateBlockInStack: no pane holds block {target_block_id} (tab {tab_id})"),
604 );
605 }
606 let mut events = super::block::handle_create_block(state, tab_id.clone(), meta);
607 let Some(block_id) = events.iter().find_map(|e| match e {
608 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
609 _ => None,
610 }) else {
611 return events; };
613 events.extend(handle_layout_stack_push(state, tab_id, target_block_id, block_id, activate, String::new()));
614 events
615}
616
617pub(super) fn handle_layout_stack_move(
632 state: &mut State,
633 tab_id: String,
634 block_id: String,
635 target_block_id: String,
636 position: agentmux_common::StackMovePosition,
637 activate: bool,
638 correlation_id: String,
639) -> Vec<Event> {
640 let not_found = format!("block {block_id} or {target_block_id} not found");
641 let emptied_source = state
642 .tabs
643 .get(&tab_id)
644 .and_then(|tab| tab.rootnode.as_ref())
645 .and_then(|root| crate::backend::layout::source_leaf_emptied_by_move(root, &block_id, &target_block_id));
646 let Some(source_node_id) = emptied_source else {
647 return stack_tree_edit(state, "LayoutStackMove", tab_id, correlation_id, not_found, |root| {
648 crate::backend::layout::move_stack_member(root, &block_id, &target_block_id, position, activate)
649 });
650 };
651
652 let tab = state.tabs.get_mut(&tab_id).expect("resolved above");
655 let pre_focused = tab.focused_node_id.clone();
656 let pre_magnified = tab.magnified_node_id.clone();
657 let applied = tab.rootnode.as_mut().is_some_and(|root| {
658 crate::backend::layout::move_stack_member(root, &block_id, &target_block_id, position, activate)
659 });
660 if !applied {
661 return op_error(state, format!("LayoutStackMove: {not_found} (tab {tab_id})"));
662 }
663 let (was_focused, was_magnified) = clear_dangling_focus_magnify(tab, &pre_focused, &pre_magnified);
664 let new_tree = tab.rootnode.clone();
665 let violations = crate::backend::layout::validate_layout_invariants(&new_tree);
666 if !violations.is_empty() {
667 tracing::error!(tab_id = %tab_id, op = "LayoutStackMove", violations = ?violations, "layout-doctor: invariant violation(s) after a pane-tab edit");
668 }
669 let v = state.bump_version();
670 vec![Event::LayoutNodeDeleted {
671 tab_id,
672 node_id: source_node_id,
673 new_tree,
674 tree_cleared: false,
676 was_focused,
677 was_magnified,
678 correlation_id,
679 version: v,
680 }]
681}
682
683fn unknown_tab(state: &mut State, op: &str, tab_id: &str) -> Vec<Event> {
684 let v = state.bump_version();
685 vec![Event::Error {
686 code: ErrorCode::InvalidCommand,
687 message: format!("{}: unknown tab {}", op, tab_id),
688 fatal: false,
689 version: v,
690 }]
691}
692
693fn op_error(state: &mut State, message: String) -> Vec<Event> {
695 let v = state.bump_version();
696 vec![Event::Error {
697 code: ErrorCode::InvalidCommand,
698 message,
699 fatal: false,
700 version: v,
701 }]
702}
703
704fn reconcile_focus_magnify(tab: &mut crate::state::TabRecord) {
709 let resolves = |id: &str| -> bool {
710 if id.is_empty() {
711 return true;
712 }
713 match tab.rootnode.as_ref() {
714 Some(root) => crate::backend::layout::find_node_by_id(root, id).is_some(),
715 None => false,
716 }
717 };
718 let drop_focus = !resolves(&tab.focused_node_id);
719 let drop_magnify = !resolves(&tab.magnified_node_id);
720 if drop_focus {
721 tab.focused_node_id.clear();
722 }
723 if drop_magnify {
724 tab.magnified_node_id.clear();
725 }
726}
727
728pub(super) fn handle_layout_move_node(
729 state: &mut State,
730 tab_id: String,
731 node_id: String,
732 new_parent_id: String,
733 index: usize,
734 correlation_id: String,
735) -> Vec<Event> {
736 if let Err(events) = apply_atomic(state, &tab_id, "LayoutMoveNode", |root| {
737 crate::backend::layout::move_node(root, &node_id, &new_parent_id, index)
738 }) {
739 return events;
740 }
741 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
742 reconcile_focus_magnify(tab);
743 let new_tree = tab.rootnode.clone();
744 let v = state.bump_version();
745 vec![Event::LayoutNodeMoved {
746 tab_id,
747 new_tree,
748 node_id,
749 new_parent_id,
750 index,
751 correlation_id,
752 version: v,
753 }]
754}
755
756pub(super) fn handle_layout_swap_nodes(
757 state: &mut State,
758 tab_id: String,
759 node1_id: String,
760 node2_id: String,
761 correlation_id: String,
762) -> Vec<Event> {
763 if let Err(events) = apply_atomic(state, &tab_id, "LayoutSwapNodes", |root| {
764 crate::backend::layout::swap_nodes(root, &node1_id, &node2_id)
765 }) {
766 return events;
767 }
768 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
769 reconcile_focus_magnify(tab);
770 let new_tree = tab.rootnode.clone();
771 let v = state.bump_version();
772 vec![Event::LayoutNodesSwapped {
773 tab_id,
774 new_tree,
775 node1_id,
776 node2_id,
777 correlation_id,
778 version: v,
779 }]
780}
781
782pub(super) fn handle_layout_resize_nodes(
783 state: &mut State,
784 tab_id: String,
785 ops: Vec<agentmux_common::ResizeOp>,
786 correlation_id: String,
787) -> Vec<Event> {
788 if let Err(events) = apply_atomic(state, &tab_id, "LayoutResizeNodes", |root| {
789 crate::backend::layout::resize_nodes(root, &ops)
790 }) {
791 return events;
792 }
793 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
794 reconcile_focus_magnify(tab);
795 let new_tree = tab.rootnode.clone();
796 let v = state.bump_version();
797 vec![Event::LayoutNodesResized {
798 tab_id,
799 new_tree,
800 ops,
801 correlation_id,
802 version: v,
803 }]
804}
805
806pub(super) fn handle_layout_replace_node(
807 state: &mut State,
808 tab_id: String,
809 target_id: String,
810 new_node: agentmux_common::LayoutNode,
811 focus_after: bool,
812 correlation_id: String,
813) -> Vec<Event> {
814 let new_id = new_node.id.clone();
815 let new_node_event = new_node.clone();
816 if let Err(events) = apply_atomic(state, &tab_id, "LayoutReplaceNode", |root| {
817 crate::backend::layout::replace_node(root, &target_id, new_node)
818 }) {
819 return events;
820 }
821 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
822 if focus_after {
823 tab.focused_node_id = new_id;
824 }
825 reconcile_focus_magnify(tab);
826 let new_tree = tab.rootnode.clone();
827 let v = state.bump_version();
828 vec![Event::LayoutNodeReplaced {
829 tab_id,
830 new_tree,
831 target_id,
832 new_node: new_node_event,
833 correlation_id,
834 version: v,
835 }]
836}
837
838pub(super) fn handle_layout_split_horizontal(
839 state: &mut State,
840 tab_id: String,
841 target_id: String,
842 new_node: agentmux_common::LayoutNode,
843 position: agentmux_common::SplitPosition,
844 focus_after: bool,
845 correlation_id: String,
846) -> Vec<Event> {
847 let new_id = new_node.id.clone();
848 let new_node_event = new_node.clone();
849 if let Err(events) = apply_atomic(state, &tab_id, "LayoutSplitHorizontal", |root| {
850 crate::backend::layout::split_horizontal(root, &target_id, new_node, position)
851 }) {
852 return events;
853 }
854 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
855 if focus_after {
856 tab.focused_node_id = new_id;
857 }
858 reconcile_focus_magnify(tab);
859 let new_tree = tab.rootnode.clone();
860 let v = state.bump_version();
861 vec![Event::LayoutSplitHorizontalApplied {
862 tab_id,
863 new_tree,
864 target_id,
865 new_node: new_node_event,
866 position,
867 correlation_id,
868 version: v,
869 }]
870}
871
872pub(super) fn handle_layout_split_vertical(
873 state: &mut State,
874 tab_id: String,
875 target_id: String,
876 new_node: agentmux_common::LayoutNode,
877 position: agentmux_common::SplitPosition,
878 focus_after: bool,
879 correlation_id: String,
880) -> Vec<Event> {
881 let new_id = new_node.id.clone();
882 let new_node_event = new_node.clone();
883 if let Err(events) = apply_atomic(state, &tab_id, "LayoutSplitVertical", |root| {
884 crate::backend::layout::split_vertical(root, &target_id, new_node, position)
885 }) {
886 return events;
887 }
888 let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
889 if focus_after {
890 tab.focused_node_id = new_id;
891 }
892 reconcile_focus_magnify(tab);
893 let new_tree = tab.rootnode.clone();
894 let v = state.bump_version();
895 vec![Event::LayoutSplitVerticalApplied {
896 tab_id,
897 new_tree,
898 target_id,
899 new_node: new_node_event,
900 position,
901 correlation_id,
902 version: v,
903 }]
904}
905
906pub(super) fn handle_layout_insert_node_at_index(
907 state: &mut State,
908 tab_id: String,
909 node: agentmux_common::LayoutNode,
910 index_arr: Vec<usize>,
911 focus_after: bool,
912 magnify_after: bool,
913 correlation_id: String,
914) -> Vec<Event> {
915 let new_id = node.id.clone();
916 let node_event = node.clone();
917 let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
918 let Some(tab) = state.tabs.get_mut(&tab_id) else {
919 return unknown_tab(state, "LayoutInsertNodeAtIndex", &tab_id);
920 };
921 let working_result: Result<agentmux_common::LayoutNode, String> = match tab.rootnode.as_ref() {
929 Some(root) => {
930 let mut w = root.clone();
931 crate::backend::layout::insert_node_at_index(&mut w, node, &index_arr)
932 .map(|_| w)
933 .map_err(|e| format!("LayoutInsertNodeAtIndex: {} (tab {})", e, tab_id))
934 }
935 None => Ok(node),
936 };
937 let mut working = match working_result {
938 Ok(w) => w,
939 Err(msg) => return op_error(state, msg),
940 };
941 if let Err(e) = crate::backend::layout::balance_node(&mut working) {
942 return op_error(
943 state,
944 format!("LayoutInsertNodeAtIndex balance: {} (tab {})", e, tab_id),
945 );
946 }
947 tab.rootnode = Some(working);
948 if focus_after || magnify_after {
950 tab.focused_node_id = new_id.clone();
951 }
952 if magnify_after {
953 tab.magnified_node_id = new_id;
954 }
955 let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
959 if pruned > 0 {
960 tracing::warn!(
961 tab_id = %tab_id,
962 pruned,
963 "reducer: LayoutInsertNodeAtIndex pruned dangling layout leaf/leaves referencing since-deleted blocks"
964 );
965 }
966 let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
968 if snapped > 0 {
969 tracing::warn!(
970 tab_id = %tab_id,
971 snapped,
972 "reducer: LayoutInsertNodeAtIndex snapped minimize-locked node size(s) back to their locked values"
973 );
974 }
975 let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
977 if !violations.is_empty() {
978 tracing::error!(
979 tab_id = %tab_id,
980 violations = ?violations,
981 "layout-doctor: invariant violation(s) in tree persisted by LayoutInsertNodeAtIndex"
982 );
983 }
984 reconcile_focus_magnify(tab);
985 let new_tree = tab.rootnode.clone();
986 let v = state.bump_version();
987 vec![Event::LayoutNodeInsertedAtIndex {
988 tab_id,
989 new_tree,
990 node: node_event,
991 index_arr,
992 correlation_id,
993 version: v,
994 }]
995}
996
997fn apply_atomic<F>(
1008 state: &mut State,
1009 tab_id: &str,
1010 op: &str,
1011 f: F,
1012) -> Result<(), Vec<Event>>
1013where
1014 F: FnOnce(&mut agentmux_common::LayoutNode) -> Result<(), crate::backend::layout::LayoutError>,
1015{
1016 let Some(tab) = state.tabs.get_mut(tab_id) else {
1017 return Err(unknown_tab(state, op, tab_id));
1018 };
1019 let Some(current) = tab.rootnode.as_ref() else {
1020 return Err(op_error(state, format!("{}: empty tree (tab {})", op, tab_id)));
1021 };
1022 let mut working = current.clone();
1023 if let Err(e) = f(&mut working) {
1024 return Err(op_error(state, format!("{}: {} (tab {})", op, e, tab_id)));
1025 }
1026 if let Err(e) = crate::backend::layout::balance_node(&mut working) {
1027 return Err(op_error(state, format!("{} balance: {} (tab {})", op, e, tab_id)));
1028 }
1029 tab.rootnode = Some(working);
1030 let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
1040 let tab = state.tabs.get_mut(tab_id).expect("tab present — just written above");
1041 let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
1042 if pruned > 0 {
1043 tracing::warn!(
1044 tab_id = %tab_id,
1045 op,
1046 pruned,
1047 "reducer: pruned dangling layout leaf/leaves referencing since-deleted blocks"
1048 );
1049 }
1050 let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
1056 if snapped > 0 {
1057 tracing::warn!(
1058 tab_id = %tab_id,
1059 op,
1060 snapped,
1061 "reducer: snapped minimize-locked node size(s) back to their locked values"
1062 );
1063 }
1064 let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
1066 if !violations.is_empty() {
1067 tracing::error!(
1068 tab_id = %tab_id,
1069 op,
1070 violations = ?violations,
1071 "layout-doctor: invariant violation(s) in tree persisted by structural op"
1072 );
1073 }
1074 Ok(())
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use crate::reducer::test_support::*;
1081 use crate::reducer::update;
1082 use agentmux_common::ipc::Command;
1083
1084 #[test]
1089 fn set_focused_node_round_trip_emits_event_and_updates_state() {
1090 let mut state = State::default();
1091 let ws_id = create_workspace(&mut state, "w");
1092 let tab_id = create_tab(&mut state, &ws_id, "t1");
1093 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
1094 let events = update(
1095 &mut state,
1096 Command::SetFocusedNode {
1097 tab_id: tab_id.clone(),
1098 node_id: "node-7".into(),
1099 },
1100 &ctx(2),
1101 );
1102 assert_eq!(events.len(), 1);
1103 match &events[0] {
1104 Event::FocusedNodeChanged { tab_id: t, node_id, .. } => {
1105 assert_eq!(t, &tab_id);
1106 assert_eq!(node_id, "node-7");
1107 }
1108 other => panic!("expected FocusedNodeChanged, got {:?}", other),
1109 }
1110 assert_eq!(state.tabs[&tab_id].focused_node_id, "node-7");
1111 }
1112
1113 #[test]
1114 fn set_focused_node_no_op_when_value_unchanged() {
1115 let mut state = State::default();
1116 let ws_id = create_workspace(&mut state, "w");
1117 let tab_id = create_tab(&mut state, &ws_id, "t1");
1118 let _ = update(
1119 &mut state,
1120 Command::SetFocusedNode {
1121 tab_id: tab_id.clone(),
1122 node_id: "node-1".into(),
1123 },
1124 &ctx(2),
1125 );
1126 let version_before = state.event_version;
1127 let events = update(
1128 &mut state,
1129 Command::SetFocusedNode {
1130 tab_id,
1131 node_id: "node-1".into(),
1132 },
1133 &ctx(3),
1134 );
1135 assert!(events.is_empty(), "no-op should emit no events");
1136 assert_eq!(state.event_version, version_before, "no version bump on no-op");
1137 }
1138
1139 #[test]
1140 fn set_focused_node_unknown_tab_emits_error() {
1141 let mut state = State::default();
1142 let events = update(
1143 &mut state,
1144 Command::SetFocusedNode {
1145 tab_id: "ghost-tab".into(),
1146 node_id: "node-1".into(),
1147 },
1148 &ctx(1),
1149 );
1150 assert_eq!(events.len(), 1);
1151 assert!(
1152 matches!(&events[0], Event::Error { .. }),
1153 "expected Event::Error, got {:?}",
1154 events[0]
1155 );
1156 }
1157
1158 #[test]
1159 fn set_magnified_node_round_trip_emits_event_and_updates_state() {
1160 let mut state = State::default();
1161 let ws_id = create_workspace(&mut state, "w");
1162 let tab_id = create_tab(&mut state, &ws_id, "t1");
1163 let events = update(
1164 &mut state,
1165 Command::SetMagnifiedNode {
1166 tab_id: tab_id.clone(),
1167 node_id: "node-9".into(),
1168 },
1169 &ctx(2),
1170 );
1171 assert_eq!(events.len(), 1);
1172 match &events[0] {
1173 Event::MagnifiedNodeChanged { tab_id: t, node_id, .. } => {
1174 assert_eq!(t, &tab_id);
1175 assert_eq!(node_id, "node-9");
1176 }
1177 other => panic!("expected MagnifiedNodeChanged, got {:?}", other),
1178 }
1179 assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-9");
1180 }
1181
1182 #[test]
1183 fn set_magnified_node_no_op_when_value_unchanged() {
1184 let mut state = State::default();
1185 let ws_id = create_workspace(&mut state, "w");
1186 let tab_id = create_tab(&mut state, &ws_id, "t1");
1187 let _ = update(
1188 &mut state,
1189 Command::SetMagnifiedNode {
1190 tab_id: tab_id.clone(),
1191 node_id: "node-2".into(),
1192 },
1193 &ctx(2),
1194 );
1195 let version_before = state.event_version;
1196 let events = update(
1197 &mut state,
1198 Command::SetMagnifiedNode {
1199 tab_id,
1200 node_id: "node-2".into(),
1201 },
1202 &ctx(3),
1203 );
1204 assert!(events.is_empty());
1205 assert_eq!(state.event_version, version_before);
1206 }
1207
1208 #[test]
1209 fn set_magnified_node_clear_with_empty_node_id() {
1210 let mut state = State::default();
1211 let ws_id = create_workspace(&mut state, "w");
1212 let tab_id = create_tab(&mut state, &ws_id, "t1");
1213 let _ = update(
1215 &mut state,
1216 Command::SetMagnifiedNode {
1217 tab_id: tab_id.clone(),
1218 node_id: "node-3".into(),
1219 },
1220 &ctx(2),
1221 );
1222 assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-3");
1223 let events = update(
1225 &mut state,
1226 Command::SetMagnifiedNode {
1227 tab_id: tab_id.clone(),
1228 node_id: String::new(),
1229 },
1230 &ctx(3),
1231 );
1232 assert_eq!(events.len(), 1);
1233 match &events[0] {
1234 Event::MagnifiedNodeChanged { node_id, .. } => assert_eq!(node_id, ""),
1235 other => panic!("expected MagnifiedNodeChanged with empty node_id, got {:?}", other),
1236 }
1237 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
1238 }
1239
1240 #[test]
1241 fn set_magnified_node_unknown_tab_emits_error() {
1242 let mut state = State::default();
1243 let events = update(
1244 &mut state,
1245 Command::SetMagnifiedNode {
1246 tab_id: "ghost-tab".into(),
1247 node_id: "node-1".into(),
1248 },
1249 &ctx(1),
1250 );
1251 assert_eq!(events.len(), 1);
1252 assert!(matches!(&events[0], Event::Error { .. }));
1253 }
1254
1255 use proptest::prelude::*;
1281
1282 #[derive(Debug, Clone)]
1287 enum PropOp {
1288 CreateWorkspace,
1289 CreateTab,
1290 CreateBlock,
1291 DeleteTab,
1292 DeleteBlock,
1293 DeleteWorkspace,
1294 }
1295
1296 fn op_strategy() -> impl Strategy<Value = PropOp> {
1297 prop_oneof![
1301 4 => Just(PropOp::CreateWorkspace),
1302 4 => Just(PropOp::CreateTab),
1303 3 => Just(PropOp::CreateBlock),
1304 1 => Just(PropOp::DeleteTab),
1305 1 => Just(PropOp::DeleteBlock),
1306 1 => Just(PropOp::DeleteWorkspace),
1307 ]
1308 }
1309
1310 fn apply_prop_op(state: &mut State, op: PropOp, conn_id: u64) -> Vec<Event> {
1313 match op {
1314 PropOp::CreateWorkspace => update(
1315 state,
1316 Command::CreateWorkspace { name: format!("ws-{}", conn_id) },
1317 &ctx(conn_id),
1318 ),
1319 PropOp::CreateTab => {
1320 let target_ws = state.workspaces.keys().next().cloned();
1321 match target_ws {
1322 Some(workspace_id) => update(
1323 state,
1324 Command::CreateTab {
1325 workspace_id,
1326 name: format!("tab-{}", conn_id),
1327 },
1328 &ctx(conn_id),
1329 ),
1330 None => Vec::new(),
1331 }
1332 }
1333 PropOp::CreateBlock => {
1334 let target_tab = state.tabs.keys().next().cloned();
1335 match target_tab {
1336 Some(tab_id) => update(
1337 state,
1338 Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
1339 &ctx(conn_id),
1340 ),
1341 None => Vec::new(),
1342 }
1343 }
1344 PropOp::DeleteTab => {
1345 if let Some((tab_id, tab)) = state.tabs.iter().next() {
1346 let cmd = Command::DeleteTab {
1347 workspace_id: tab.workspace_id.clone(),
1348 tab_id: tab_id.clone(),
1349 force: true,
1354 };
1355 update(state, cmd, &ctx(conn_id))
1356 } else {
1357 Vec::new()
1358 }
1359 }
1360 PropOp::DeleteBlock => {
1361 if let Some((block_id, block)) = state.blocks.iter().next() {
1362 let cmd = Command::DeleteBlock {
1363 tab_id: block.tab_id.clone(),
1364 block_id: block_id.clone(),
1365 };
1366 update(state, cmd, &ctx(conn_id))
1367 } else {
1368 Vec::new()
1369 }
1370 }
1371 PropOp::DeleteWorkspace => {
1372 if let Some(workspace_id) = state.workspaces.keys().next().cloned() {
1373 update(
1374 state,
1375 Command::DeleteWorkspace { workspace_id, force: false },
1376 &ctx(conn_id),
1377 )
1378 } else {
1379 Vec::new()
1380 }
1381 }
1382 }
1383 }
1384
1385 fn assert_invariants(state: &State) {
1388 for (tab_id, tab) in &state.tabs {
1390 assert!(
1391 state.workspaces.contains_key(&tab.workspace_id),
1392 "tab {} references unknown workspace {}",
1393 tab_id,
1394 tab.workspace_id
1395 );
1396 }
1397 for (block_id, block) in &state.blocks {
1399 assert!(
1400 state.tabs.contains_key(&block.tab_id),
1401 "block {} references unknown tab {}",
1402 block_id,
1403 block.tab_id
1404 );
1405 }
1406 for (workspace_id, ws) in &state.workspaces {
1408 for tab_id in &ws.tab_ids {
1409 assert!(
1410 state.tabs.contains_key(tab_id),
1411 "workspace {} tab_ids contains unknown tab {}",
1412 workspace_id,
1413 tab_id
1414 );
1415 }
1416 }
1417 for (tab_id, tab) in &state.tabs {
1419 for block_id in &tab.block_ids {
1420 assert!(
1421 state.blocks.contains_key(block_id),
1422 "tab {} block_ids contains unknown block {}",
1423 tab_id,
1424 block_id
1425 );
1426 }
1427 }
1428 for (workspace_id, ws) in &state.workspaces {
1430 if let Some(active) = &ws.active_tab_id {
1431 assert!(
1432 ws.tab_ids.iter().any(|t| t == active),
1433 "workspace {} active_tab_id {} not in its tab_ids",
1434 workspace_id,
1435 active
1436 );
1437 }
1438 }
1439 }
1440
1441 proptest! {
1442 #![proptest_config(ProptestConfig {
1443 cases: 64,
1444 ..ProptestConfig::default()
1445 })]
1446
1447 #[test]
1452 fn invariants_hold_across_random_sequences(ops in prop::collection::vec(op_strategy(), 0..40)) {
1453 let mut state = State::default();
1454 let mut last_version: u64 = 0;
1455 for (i, op) in ops.into_iter().enumerate() {
1456 let events = apply_prop_op(&mut state, op, (i + 1) as u64);
1457 for ev in &events {
1458 let v = extract_version(ev);
1459 prop_assert!(
1460 v > last_version,
1461 "version {} not strictly greater than previous {} (event {:?})",
1462 v,
1463 last_version,
1464 ev
1465 );
1466 last_version = v;
1467 }
1468 assert_invariants(&state);
1469 }
1470 }
1471
1472 #[test]
1477 fn delete_workspace_cascades_cleanly(
1478 tab_count in 1usize..6,
1479 blocks_per_tab in 0usize..4,
1480 ) {
1481 let mut state = State::default();
1482 let ws_events = update(
1484 &mut state,
1485 Command::CreateWorkspace { name: "ws".into() },
1486 &ctx(1),
1487 );
1488 let ws_id = ws_events
1489 .iter()
1490 .find_map(|e| match e {
1491 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
1492 _ => None,
1493 })
1494 .unwrap();
1495 for _ in 0..tab_count {
1500 let evs = update(
1501 &mut state,
1502 Command::CreateTab {
1503 workspace_id: ws_id.clone(),
1504 name: "t".into(),
1505 },
1506 &ctx(2),
1507 );
1508 let tid = evs
1509 .iter()
1510 .find_map(|e| match e {
1511 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1512 _ => None,
1513 })
1514 .unwrap();
1515 for _ in 0..blocks_per_tab {
1516 let _ = update(
1517 &mut state,
1518 Command::CreateBlock {
1519 tab_id: tid.clone(),
1520 meta: serde_json::Value::Null,
1521 },
1522 &ctx(3),
1523 );
1524 }
1525 }
1526 prop_assert_eq!(state.workspaces.len(), 1);
1528 prop_assert_eq!(state.tabs.len(), tab_count);
1529 prop_assert_eq!(state.blocks.len(), tab_count * blocks_per_tab);
1530 let _ = update(
1532 &mut state,
1533 Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
1534 &ctx(4),
1535 );
1536 prop_assert!(state.workspaces.is_empty());
1538 prop_assert!(state.tabs.is_empty());
1539 prop_assert!(
1540 state.blocks.is_empty(),
1541 "blocks should cascade-delete with their tabs; got {} survivors",
1542 state.blocks.len()
1543 );
1544 assert_invariants(&state);
1546 }
1547 }
1548
1549 fn leaf_node(id: &str, block_id: &str) -> agentmux_common::LayoutNode {
1558 agentmux_common::LayoutNode {
1559 id: id.to_string(),
1560 size: 1.0,
1561 data: Some(agentmux_common::LayoutNodeData {
1562 block_id: block_id.to_string(),
1563 ..Default::default()
1564 }),
1565 ..Default::default()
1566 }
1567 }
1568
1569 fn fresh_tab() -> (State, String) {
1570 let mut state = State::default();
1571 let ws = create_workspace(&mut state, "w");
1572 let tab = create_tab(&mut state, &ws, "t");
1573 (state, tab)
1574 }
1575
1576 fn seed_block(state: &mut State, tab_id: &str, block_id: &str) {
1588 state.blocks.insert(
1589 block_id.to_string(),
1590 crate::state::BlockRecord {
1591 block_id: block_id.to_string(),
1592 tab_id: tab_id.to_string(),
1593 },
1594 );
1595 }
1596
1597 #[test]
1598 fn layout_clear_wipes_rootnode_focus_magnify_and_emits_event() {
1599 let (mut state, tab_id) = fresh_tab();
1600 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n1", "b1"));
1602 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n1".into();
1603 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n1".into();
1604
1605 let events = update(
1606 &mut state,
1607 Command::LayoutClear {
1608 tab_id: tab_id.clone(),
1609 correlation_id: "corr-1".into(),
1610 },
1611 &ctx(1),
1612 );
1613
1614 assert_eq!(events.len(), 1);
1615 assert!(matches!(
1616 &events[0],
1617 Event::LayoutCleared { correlation_id, .. } if correlation_id == "corr-1"
1618 ));
1619 let tab = &state.tabs[&tab_id];
1620 assert!(tab.rootnode.is_none(), "rootnode wiped");
1621 assert_eq!(tab.focused_node_id, "");
1622 assert_eq!(tab.magnified_node_id, "");
1623 }
1624
1625 #[test]
1626 fn layout_queue_backend_actions_passes_through_and_emits_event() {
1627 let (mut state, tab_id) = fresh_tab();
1628 let actions = serde_json::json!([{
1629 "actiontype": "insert",
1630 "actionid": "a1",
1631 "blockid": "b1",
1632 "nodesize": null,
1633 "nodesizefraction": null,
1634 "indexarr": null,
1635 "focused": true,
1636 "magnified": false,
1637 "ephemeral": false,
1638 "targetblockid": "",
1639 "position": "",
1640 }]);
1641 let events = update(
1642 &mut state,
1643 Command::LayoutQueueBackendActions {
1644 tab_id: tab_id.clone(),
1645 actions: actions.clone(),
1646 correlation_id: "corr-q1".into(),
1647 },
1648 &ctx(1),
1649 );
1650 assert_eq!(events.len(), 1);
1651 match &events[0] {
1652 Event::LayoutBackendActionsQueued {
1653 tab_id: ev_tab_id,
1654 actions: ev_actions,
1655 correlation_id,
1656 ..
1657 } => {
1658 assert_eq!(ev_tab_id, &tab_id);
1659 assert_eq!(ev_actions, &actions);
1660 assert_eq!(correlation_id, "corr-q1");
1661 }
1662 other => panic!("expected LayoutBackendActionsQueued, got {other:?}"),
1663 }
1664 }
1665
1666 #[test]
1667 fn layout_queue_backend_actions_unknown_tab_emits_error() {
1668 let mut state = State::default();
1669 let events = update(
1670 &mut state,
1671 Command::LayoutQueueBackendActions {
1672 tab_id: "nope".into(),
1673 actions: serde_json::json!([{}]),
1674 correlation_id: "corr".into(),
1675 },
1676 &ctx(1),
1677 );
1678 assert_eq!(events.len(), 1);
1679 assert!(matches!(&events[0], Event::Error { .. }));
1680 }
1681
1682 #[test]
1683 fn layout_queue_backend_actions_empty_array_emits_error() {
1684 let (mut state, tab_id) = fresh_tab();
1685 let events = update(
1686 &mut state,
1687 Command::LayoutQueueBackendActions {
1688 tab_id,
1689 actions: serde_json::json!([]),
1690 correlation_id: "corr".into(),
1691 },
1692 &ctx(1),
1693 );
1694 assert_eq!(events.len(), 1);
1695 assert!(matches!(&events[0], Event::Error { .. }));
1696 }
1697
1698 #[test]
1699 fn layout_clear_unknown_tab_emits_error() {
1700 let mut state = State::default();
1701 let events = update(
1702 &mut state,
1703 Command::LayoutClear {
1704 tab_id: "nope".into(),
1705 correlation_id: "corr".into(),
1706 },
1707 &ctx(1),
1708 );
1709 assert!(matches!(&events[0], Event::Error { code: ErrorCode::InvalidCommand, .. }));
1710 }
1711
1712 #[test]
1713 fn layout_set_tree_replaces_rootnode_wholesale() {
1714 let (mut state, tab_id) = fresh_tab();
1715 seed_block(&mut state, &tab_id, "b1");
1716 seed_block(&mut state, &tab_id, "b2");
1717 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("old", "b1"));
1718
1719 let new_tree = Some(leaf_node("new", "b2"));
1720 let events = update(
1721 &mut state,
1722 Command::LayoutSetTree {
1723 tab_id: tab_id.clone(),
1724 new_tree: new_tree.clone(),
1725 correlation_id: "corr-set".into(),
1726 slices: None,
1727 },
1728 &ctx(1),
1729 );
1730
1731 assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1732 assert_eq!(state.tabs[&tab_id].rootnode, new_tree);
1733 }
1734
1735 #[test]
1736 fn layout_set_tree_to_none_clears_rootnode() {
1737 let (mut state, tab_id) = fresh_tab();
1738 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
1739
1740 let _ = update(
1741 &mut state,
1742 Command::LayoutSetTree {
1743 tab_id: tab_id.clone(),
1744 new_tree: None,
1745 correlation_id: "corr".into(),
1746 slices: None,
1747 },
1748 &ctx(1),
1749 );
1750 assert!(state.tabs[&tab_id].rootnode.is_none());
1751 }
1752
1753 #[test]
1761 fn layout_set_tree_self_heals_a_dangling_block_ref_in_the_pushed_tree() {
1762 let (mut state, tab_id) = fresh_tab();
1763 seed_block(&mut state, &tab_id, "ba");
1764
1765 let pushed_tree = agentmux_common::LayoutNode {
1766 id: "root".into(),
1767 children: vec![leaf_node("a", "ba"), leaf_node("b", "gone")],
1768 ..Default::default()
1769 };
1770 let events = update(
1771 &mut state,
1772 Command::LayoutSetTree {
1773 tab_id: tab_id.clone(),
1774 new_tree: Some(pushed_tree),
1775 correlation_id: "corr".into(),
1776 slices: None,
1777 },
1778 &ctx(1),
1779 );
1780
1781 assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1782 assert!(tree_has(&state, &tab_id, "a"), "live block's leaf survives");
1783 assert!(!tree_has(&state, &tab_id, "b"), "dangling leaf pruned, not persisted");
1784 if let Event::LayoutTreeReplaced { new_tree, .. } = &events[0] {
1789 let root = new_tree.as_ref().unwrap();
1790 assert!(
1791 !root.children.iter().any(|c| c.id == "b"),
1792 "emitted event's new_tree must already reflect the prune"
1793 );
1794 }
1795 }
1796
1797 #[test]
1804 fn layout_set_tree_snaps_tampered_minimized_size_back_to_its_lock() {
1805 let (mut state, tab_id) = fresh_tab();
1806 seed_block(&mut state, &tab_id, "b1");
1807 seed_block(&mut state, &tab_id, "b2");
1808
1809 let mut minimized = leaf_node("min", "b1");
1810 minimized.size = 120.0; minimized
1812 .extra
1813 .insert("minimizedSize".into(), serde_json::json!(200.0));
1814 minimized
1815 .extra
1816 .insert("minimizedLockedSize".into(), serde_json::json!(33.0));
1817 let mut sibling = leaf_node("sib", "b2");
1818 sibling.size = 280.0;
1819 let pushed_tree = agentmux_common::LayoutNode {
1820 id: "root".into(),
1821 children: vec![minimized, sibling],
1822 ..Default::default()
1823 };
1824
1825 let events = update(
1826 &mut state,
1827 Command::LayoutSetTree {
1828 tab_id: tab_id.clone(),
1829 new_tree: Some(pushed_tree),
1830 correlation_id: "corr".into(),
1831 slices: None,
1832 },
1833 &ctx(1),
1834 );
1835
1836 assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1837 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
1838 assert_eq!(root.children[0].size, 33.0, "tampered size snapped back to lock");
1839 assert_eq!(root.children[1].size, 367.0, "delta repaid to the unlocked sibling");
1840 if let Event::LayoutTreeReplaced { new_tree, .. } = &events[0] {
1843 assert_eq!(new_tree.as_ref().unwrap().children[0].size, 33.0);
1844 }
1845 }
1846
1847 #[test]
1853 fn layout_insert_node_rejects_minimize_locked_parent() {
1854 let (mut state, tab_id) = fresh_tab();
1855 seed_block(&mut state, &tab_id, "b1");
1856 seed_block(&mut state, &tab_id, "b2");
1857 seed_block(&mut state, &tab_id, "b3");
1858
1859 let mut l1 = leaf_node("l1", "b1");
1860 l1.extra.insert("minimizedSize".into(), serde_json::json!(200.0));
1861 let mut l2 = leaf_node("l2", "b2");
1862 l2.extra.insert("minimizedSize".into(), serde_json::json!(200.0));
1863 let mut dissolved = agentmux_common::LayoutNode {
1864 id: "colA".into(),
1865 children: vec![l1, l2],
1866 ..Default::default()
1867 };
1868 dissolved
1869 .extra
1870 .insert("columnDissolve".into(), serde_json::json!({"targetColumnId": "root"}));
1871 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(agentmux_common::LayoutNode {
1872 id: "root".into(),
1873 children: vec![dissolved, leaf_node("content", "b3")],
1874 ..Default::default()
1875 });
1876
1877 let events = update(
1878 &mut state,
1879 Command::LayoutInsertNode {
1880 tab_id: tab_id.clone(),
1881 node: leaf_node("new", "b3"),
1882 parent_id: Some("colA".into()),
1883 index: None,
1884 focus_after: false,
1885 magnify_after: false,
1886 correlation_id: "corr-locked".into(),
1887 },
1888 &ctx(1),
1889 );
1890
1891 assert!(matches!(&events[0], Event::Error { code: ErrorCode::InvalidCommand, .. }));
1892 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
1894 assert_eq!(root.children[0].children.len(), 2);
1895 }
1896
1897 #[test]
1901 fn layout_set_tree_with_slices_applies_focus_magnify() {
1902 let (mut state, tab_id) = fresh_tab();
1903 seed_block(&mut state, &tab_id, "b1");
1904 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "stale".into();
1905
1906 let events = update(
1907 &mut state,
1908 Command::LayoutSetTree {
1909 tab_id: tab_id.clone(),
1910 new_tree: Some(leaf_node("n1", "b1")),
1911 correlation_id: "corr".into(),
1912 slices: Some(agentmux_common::LayoutClientSlices {
1913 leaforder: None,
1914 focused_node_id: "n1".into(),
1915 magnified_node_id: String::new(),
1916 pending_backend_actions: None,
1917 }),
1918 },
1919 &ctx(1),
1920 );
1921
1922 assert_eq!(state.tabs[&tab_id].focused_node_id, "n1");
1923 assert!(
1924 state.tabs[&tab_id].magnified_node_id.is_empty(),
1925 "empty slice value clears stale magnify"
1926 );
1927 assert!(matches!(
1928 &events[0],
1929 Event::LayoutTreeReplaced { slices: Some(_), .. }
1930 ));
1931 }
1932
1933 #[test]
1936 fn layout_set_tree_none_tree_overrides_slice_focus() {
1937 let (mut state, tab_id) = fresh_tab();
1938 let _ = update(
1939 &mut state,
1940 Command::LayoutSetTree {
1941 tab_id: tab_id.clone(),
1942 new_tree: None,
1943 correlation_id: "corr".into(),
1944 slices: Some(agentmux_common::LayoutClientSlices {
1945 leaforder: None,
1946 focused_node_id: "dangling".into(),
1947 magnified_node_id: "dangling".into(),
1948 pending_backend_actions: None,
1949 }),
1950 },
1951 &ctx(1),
1952 );
1953 assert!(state.tabs[&tab_id].focused_node_id.is_empty());
1954 assert!(state.tabs[&tab_id].magnified_node_id.is_empty());
1955 }
1956
1957 #[test]
1958 fn layout_insert_node_into_empty_tree_promotes_to_root() {
1959 let (mut state, tab_id) = fresh_tab();
1960 let node = leaf_node("first", "b1");
1961 let events = update(
1962 &mut state,
1963 Command::LayoutInsertNode {
1964 tab_id: tab_id.clone(),
1965 node: node.clone(),
1966 parent_id: None,
1967 index: None,
1968 focus_after: false,
1969 magnify_after: false,
1970 correlation_id: "corr-ins".into(),
1971 },
1972 &ctx(1),
1973 );
1974 assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
1975 assert_eq!(state.tabs[&tab_id].rootnode.as_ref().map(|n| n.id.as_str()), Some("first"));
1976 }
1977
1978 #[test]
1979 fn layout_insert_node_into_existing_tree_uses_helper() {
1980 let (mut state, tab_id) = fresh_tab();
1981 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("root", "b1"));
1983 let new_node = leaf_node("added", "b2");
1984 let events = update(
1985 &mut state,
1986 Command::LayoutInsertNode {
1987 tab_id: tab_id.clone(),
1988 node: new_node,
1989 parent_id: None,
1990 index: None,
1991 focus_after: false,
1992 magnify_after: false,
1993 correlation_id: "corr".into(),
1994 },
1995 &ctx(1),
1996 );
1997 assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
1998 let root = state.tabs[&tab_id].rootnode.as_ref().expect("rootnode set");
2002 let collected = collect_block_ids(root);
2003 assert!(collected.contains(&"b1".to_string()));
2004 assert!(collected.contains(&"b2".to_string()));
2005 }
2006
2007 fn collect_block_ids(node: &agentmux_common::LayoutNode) -> Vec<String> {
2008 let mut ids = Vec::new();
2009 if let Some(d) = &node.data {
2010 if !d.block_id.is_empty() {
2011 ids.push(d.block_id.clone());
2012 }
2013 }
2014 for c in &node.children {
2015 ids.extend(collect_block_ids(c));
2016 }
2017 ids
2018 }
2019
2020 #[test]
2021 fn layout_delete_node_on_empty_tree_is_noop() {
2022 let (mut state, tab_id) = fresh_tab();
2023 let events = update(
2024 &mut state,
2025 Command::LayoutDeleteNode {
2026 tab_id: tab_id.clone(),
2027 node_id: "ghost".into(),
2028 correlation_id: "corr".into(),
2029 },
2030 &ctx(1),
2031 );
2032 assert!(events.is_empty(), "no event for delete on empty tree");
2033 assert!(state.tabs[&tab_id].rootnode.is_none());
2034 }
2035
2036 #[test]
2037 fn layout_set_tree_to_none_also_clears_focused_and_magnified() {
2038 let (mut state, tab_id) = fresh_tab();
2042 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
2043 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2044 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2045 let _ = update(
2046 &mut state,
2047 Command::LayoutSetTree {
2048 tab_id: tab_id.clone(),
2049 new_tree: None,
2050 correlation_id: "corr".into(),
2051 slices: None,
2052 },
2053 &ctx(1),
2054 );
2055 let tab = &state.tabs[&tab_id];
2056 assert!(tab.rootnode.is_none());
2057 assert_eq!(tab.focused_node_id, "");
2058 assert_eq!(tab.magnified_node_id, "");
2059 }
2060
2061 #[test]
2062 fn layout_set_tree_with_some_preserves_focused_and_magnified() {
2063 let (mut state, tab_id) = fresh_tab();
2066 seed_block(&mut state, &tab_id, "b");
2067 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2068 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2069 let _ = update(
2070 &mut state,
2071 Command::LayoutSetTree {
2072 tab_id: tab_id.clone(),
2073 new_tree: Some(leaf_node("n", "b")),
2074 correlation_id: "corr".into(),
2075 slices: None,
2076 },
2077 &ctx(1),
2078 );
2079 let tab = &state.tabs[&tab_id];
2080 assert_eq!(tab.focused_node_id, "n");
2081 assert_eq!(tab.magnified_node_id, "n");
2082 }
2083
2084 #[test]
2085 fn layout_insert_node_honours_focus_after() {
2086 let (mut state, tab_id) = fresh_tab();
2089 let node = leaf_node("new", "b1");
2090 let _ = update(
2091 &mut state,
2092 Command::LayoutInsertNode {
2093 tab_id: tab_id.clone(),
2094 node,
2095 parent_id: None,
2096 index: None,
2097 focus_after: true,
2098 magnify_after: false,
2099 correlation_id: "corr".into(),
2100 },
2101 &ctx(1),
2102 );
2103 assert_eq!(state.tabs[&tab_id].focused_node_id, "new");
2104 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
2105 }
2106
2107 #[test]
2108 fn layout_insert_node_magnify_after_implies_focus() {
2109 let (mut state, tab_id) = fresh_tab();
2114 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2115 let node = leaf_node("new", "b1");
2116 let _ = update(
2117 &mut state,
2118 Command::LayoutInsertNode {
2119 tab_id: tab_id.clone(),
2120 node,
2121 parent_id: None,
2122 index: None,
2123 focus_after: false,
2124 magnify_after: true,
2125 correlation_id: "corr".into(),
2126 },
2127 &ctx(1),
2128 );
2129 assert_eq!(
2130 state.tabs[&tab_id].focused_node_id, "new",
2131 "magnify_after must imply focus_after"
2132 );
2133 assert_eq!(state.tabs[&tab_id].magnified_node_id, "new");
2134 }
2135
2136 #[test]
2137 fn layout_insert_node_honours_explicit_parent_id_and_index() {
2138 let (mut state, tab_id) = fresh_tab();
2142 let mut group = leaf_node("group", "");
2143 group.data = None;
2144 group.children = vec![leaf_node("a", "ba"), leaf_node("c", "bc")];
2145 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2146
2147 let _ = update(
2148 &mut state,
2149 Command::LayoutInsertNode {
2150 tab_id: tab_id.clone(),
2151 node: leaf_node("b", "bb"),
2152 parent_id: Some("group".into()),
2153 index: Some(1), focus_after: false,
2155 magnify_after: false,
2156 correlation_id: "corr".into(),
2157 },
2158 &ctx(1),
2159 );
2160
2161 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2162 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2163 assert_eq!(ids, vec!["a", "b", "c"], "explicit index honoured");
2164 }
2165
2166 #[test]
2167 fn layout_insert_node_index_clamps_when_out_of_range() {
2168 let (mut state, tab_id) = fresh_tab();
2171 let mut group = leaf_node("group", "");
2172 group.data = None;
2173 group.children = vec![leaf_node("a", "ba")];
2174 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2175
2176 let _ = update(
2177 &mut state,
2178 Command::LayoutInsertNode {
2179 tab_id: tab_id.clone(),
2180 node: leaf_node("b", "bb"),
2181 parent_id: Some("group".into()),
2182 index: Some(99),
2183 focus_after: false,
2184 magnify_after: false,
2185 correlation_id: "corr".into(),
2186 },
2187 &ctx(1),
2188 );
2189 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2190 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2191 assert_eq!(ids, vec!["a", "b"], "out-of-range index clamps to end");
2192 }
2193
2194 fn group_node(id: &str, children: Vec<agentmux_common::LayoutNode>) -> agentmux_common::LayoutNode {
2196 agentmux_common::LayoutNode {
2197 id: id.to_string(),
2198 size: 10.0,
2199 children,
2200 ..Default::default()
2201 }
2202 }
2203
2204 fn tree_has(state: &State, tab_id: &str, node_id: &str) -> bool {
2205 state.tabs[tab_id].rootnode.as_ref().is_some_and(|r| {
2206 crate::backend::layout::find_node_by_id(r, node_id).is_some()
2207 })
2208 }
2209
2210 #[test]
2211 fn layout_move_node_reparents_and_emits_event() {
2212 let (mut state, tab_id) = fresh_tab();
2213 seed_block(&mut state, &tab_id, "ba");
2214 seed_block(&mut state, &tab_id, "bb");
2215 seed_block(&mut state, &tab_id, "bc");
2216 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group_node(
2217 "root",
2218 vec![
2219 group_node("g1", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]),
2220 leaf_node("c", "bc"),
2221 ],
2222 ));
2223 let events = update(
2224 &mut state,
2225 Command::LayoutMoveNode {
2226 tab_id: tab_id.clone(),
2227 node_id: "c".into(),
2228 new_parent_id: "g1".into(),
2229 index: 0,
2230 correlation_id: "corr".into(),
2231 },
2232 &ctx(1),
2233 );
2234 assert!(matches!(&events[0], Event::LayoutNodeMoved { .. }));
2235 assert!(tree_has(&state, &tab_id, "c"), "c still in tree");
2236 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2237 assert!(
2238 !root.children.iter().any(|ch| ch.id == "c"),
2239 "c reparented out of root's direct children"
2240 );
2241 }
2242
2243 #[test]
2244 fn layout_swap_nodes_swaps_positions() {
2245 let (mut state, tab_id) = fresh_tab();
2246 seed_block(&mut state, &tab_id, "ba");
2247 seed_block(&mut state, &tab_id, "bb");
2248 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2249 Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2250 let events = update(
2251 &mut state,
2252 Command::LayoutSwapNodes {
2253 tab_id: tab_id.clone(),
2254 node1_id: "a".into(),
2255 node2_id: "b".into(),
2256 correlation_id: "corr".into(),
2257 },
2258 &ctx(1),
2259 );
2260 assert!(matches!(&events[0], Event::LayoutNodesSwapped { .. }));
2261 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2262 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2263 assert_eq!(ids, vec!["b", "a"], "positions swapped");
2264 }
2265
2266 #[test]
2267 fn layout_resize_nodes_applies_sizes() {
2268 let (mut state, tab_id) = fresh_tab();
2269 seed_block(&mut state, &tab_id, "ba");
2270 seed_block(&mut state, &tab_id, "bb");
2271 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2272 Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2273 let events = update(
2274 &mut state,
2275 Command::LayoutResizeNodes {
2276 tab_id: tab_id.clone(),
2277 ops: vec![
2278 agentmux_common::ResizeOp { node_id: "a".into(), size: 30.0 },
2279 agentmux_common::ResizeOp { node_id: "b".into(), size: 70.0 },
2280 ],
2281 correlation_id: "corr".into(),
2282 },
2283 &ctx(1),
2284 );
2285 assert!(matches!(&events[0], Event::LayoutNodesResized { .. }));
2286 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2287 let a = crate::backend::layout::find_node_by_id(root, "a").unwrap();
2288 assert_eq!(a.size, 30.0);
2289 }
2290
2291 #[test]
2292 fn layout_replace_node_swaps_in_new_and_honours_focus() {
2293 let (mut state, tab_id) = fresh_tab();
2294 seed_block(&mut state, &tab_id, "ba");
2295 seed_block(&mut state, &tab_id, "bb");
2296 seed_block(&mut state, &tab_id, "bx");
2297 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2298 Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2299 let events = update(
2300 &mut state,
2301 Command::LayoutReplaceNode {
2302 tab_id: tab_id.clone(),
2303 target_id: "a".into(),
2304 new_node: leaf_node("x", "bx"),
2305 focus_after: true,
2306 correlation_id: "corr".into(),
2307 },
2308 &ctx(1),
2309 );
2310 assert!(matches!(&events[0], Event::LayoutNodeReplaced { .. }));
2311 assert!(tree_has(&state, &tab_id, "x"));
2312 assert!(!tree_has(&state, &tab_id, "a"), "a replaced");
2313 assert_eq!(state.tabs[&tab_id].focused_node_id, "x");
2314 }
2315
2316 #[test]
2317 fn layout_replace_with_malformed_node_is_atomic() {
2318 let (mut state, tab_id) = fresh_tab();
2323 let original = group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]);
2324 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(original.clone());
2325 let malformed = agentmux_common::LayoutNode {
2326 id: "bad".into(),
2327 data: Some(agentmux_common::LayoutNodeData {
2330 block_id: "x".into(),
2331 ..Default::default()
2332 }),
2333 children: vec![leaf_node("inner", "bi")],
2334 ..Default::default()
2335 };
2336 let events = update(
2337 &mut state,
2338 Command::LayoutReplaceNode {
2339 tab_id: tab_id.clone(),
2340 target_id: "a".into(),
2341 new_node: malformed,
2342 focus_after: false,
2343 correlation_id: "c".into(),
2344 },
2345 &ctx(1),
2346 );
2347 assert!(matches!(&events[0], Event::Error { .. }));
2348 assert_eq!(
2349 state.tabs[&tab_id].rootnode,
2350 Some(original),
2351 "tree left untouched on error (atomic)"
2352 );
2353 }
2354
2355 #[test]
2356 fn layout_split_horizontal_wraps_root_and_focuses_new() {
2357 let (mut state, tab_id) = fresh_tab();
2358 seed_block(&mut state, &tab_id, "ba");
2359 seed_block(&mut state, &tab_id, "bx");
2360 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("a", "ba"));
2361 let events = update(
2362 &mut state,
2363 Command::LayoutSplitHorizontal {
2364 tab_id: tab_id.clone(),
2365 target_id: "a".into(),
2366 new_node: leaf_node("x", "bx"),
2367 position: agentmux_common::SplitPosition::After,
2368 focus_after: true,
2369 correlation_id: "corr".into(),
2370 },
2371 &ctx(1),
2372 );
2373 assert!(matches!(&events[0], Event::LayoutSplitHorizontalApplied { .. }));
2374 assert!(tree_has(&state, &tab_id, "a"));
2375 assert!(tree_has(&state, &tab_id, "x"));
2376 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2377 assert!(root.data.is_none(), "root became a group");
2378 assert_eq!(root.children.len(), 2);
2379 assert_eq!(state.tabs[&tab_id].focused_node_id, "x");
2380 }
2381
2382 #[test]
2383 fn layout_split_vertical_wraps_root() {
2384 let (mut state, tab_id) = fresh_tab();
2385 seed_block(&mut state, &tab_id, "ba");
2386 seed_block(&mut state, &tab_id, "bx");
2387 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("a", "ba"));
2388 let events = update(
2389 &mut state,
2390 Command::LayoutSplitVertical {
2391 tab_id: tab_id.clone(),
2392 target_id: "a".into(),
2393 new_node: leaf_node("x", "bx"),
2394 position: agentmux_common::SplitPosition::Before,
2395 focus_after: false,
2396 correlation_id: "corr".into(),
2397 },
2398 &ctx(1),
2399 );
2400 assert!(matches!(&events[0], Event::LayoutSplitVerticalApplied { .. }));
2401 assert!(tree_has(&state, &tab_id, "a"));
2402 assert!(tree_has(&state, &tab_id, "x"));
2403 assert_eq!(state.tabs[&tab_id].rootnode.as_ref().unwrap().children.len(), 2);
2404 }
2405
2406 #[test]
2407 fn layout_insert_node_at_index_inserts_after_path() {
2408 let (mut state, tab_id) = fresh_tab();
2409 seed_block(&mut state, &tab_id, "ba");
2410 seed_block(&mut state, &tab_id, "bb");
2411 seed_block(&mut state, &tab_id, "bx");
2412 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2413 Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2414 let events = update(
2415 &mut state,
2416 Command::LayoutInsertNodeAtIndex {
2417 tab_id: tab_id.clone(),
2418 node: leaf_node("x", "bx"),
2419 index_arr: vec![0],
2420 focus_after: false,
2421 magnify_after: false,
2422 correlation_id: "corr".into(),
2423 },
2424 &ctx(1),
2425 );
2426 assert!(matches!(&events[0], Event::LayoutNodeInsertedAtIndex { .. }));
2427 let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2428 let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2429 assert_eq!(ids, vec!["a", "x", "b"], "inserted after index 0");
2430 }
2431
2432 #[test]
2433 fn layout_insert_node_at_index_into_empty_promotes_to_root() {
2434 let (mut state, tab_id) = fresh_tab();
2437 seed_block(&mut state, &tab_id, "b");
2438 let events = update(
2439 &mut state,
2440 Command::LayoutInsertNodeAtIndex {
2441 tab_id: tab_id.clone(),
2442 node: leaf_node("only", "b"),
2443 index_arr: vec![0],
2444 focus_after: false,
2445 magnify_after: false,
2446 correlation_id: "corr".into(),
2447 },
2448 &ctx(1),
2449 );
2450 assert!(matches!(&events[0], Event::LayoutNodeInsertedAtIndex { .. }));
2451 assert_eq!(
2452 state.tabs[&tab_id].rootnode.as_ref().map(|n| n.id.as_str()),
2453 Some("only"),
2454 "empty-tree insert-at-index promotes the node to root"
2455 );
2456 }
2457
2458 #[test]
2459 fn layout_move_node_unknown_tab_errors() {
2460 let mut state = State::default();
2461 let events = update(
2462 &mut state,
2463 Command::LayoutMoveNode {
2464 tab_id: "nope".into(),
2465 node_id: "a".into(),
2466 new_parent_id: "b".into(),
2467 index: 0,
2468 correlation_id: "c".into(),
2469 },
2470 &ctx(1),
2471 );
2472 assert!(matches!(
2473 &events[0],
2474 Event::Error { code: ErrorCode::InvalidCommand, .. }
2475 ));
2476 }
2477
2478 #[test]
2479 fn layout_swap_nodes_empty_tree_errors() {
2480 let (mut state, tab_id) = fresh_tab();
2481 let events = update(
2482 &mut state,
2483 Command::LayoutSwapNodes {
2484 tab_id: tab_id.clone(),
2485 node1_id: "a".into(),
2486 node2_id: "b".into(),
2487 correlation_id: "c".into(),
2488 },
2489 &ctx(1),
2490 );
2491 assert!(matches!(&events[0], Event::Error { .. }));
2492 }
2493
2494 #[test]
2495 fn layout_insert_node_into_empty_tree_with_explicit_parent_id_emits_error() {
2496 let (mut state, tab_id) = fresh_tab();
2500 let events = update(
2501 &mut state,
2502 Command::LayoutInsertNode {
2503 tab_id: tab_id.clone(),
2504 node: leaf_node("first", "b1"),
2505 parent_id: Some("does-not-exist".into()),
2506 index: None,
2507 focus_after: false,
2508 magnify_after: false,
2509 correlation_id: "corr".into(),
2510 },
2511 &ctx(1),
2512 );
2513 assert!(matches!(
2514 &events[0],
2515 Event::Error { code: ErrorCode::InvalidCommand, .. }
2516 ));
2517 assert!(
2518 state.tabs[&tab_id].rootnode.is_none(),
2519 "tree must stay empty on rejection"
2520 );
2521 }
2522
2523 #[test]
2524 fn layout_insert_node_into_empty_tree_with_explicit_index_emits_error() {
2525 let (mut state, tab_id) = fresh_tab();
2528 let events = update(
2529 &mut state,
2530 Command::LayoutInsertNode {
2531 tab_id: tab_id.clone(),
2532 node: leaf_node("first", "b1"),
2533 parent_id: None,
2534 index: Some(0),
2535 focus_after: false,
2536 magnify_after: false,
2537 correlation_id: "corr".into(),
2538 },
2539 &ctx(1),
2540 );
2541 assert!(matches!(
2542 &events[0],
2543 Event::Error { code: ErrorCode::InvalidCommand, .. }
2544 ));
2545 assert!(state.tabs[&tab_id].rootnode.is_none());
2546 }
2547
2548 #[test]
2549 fn layout_insert_node_with_unknown_parent_id_emits_error() {
2550 let (mut state, tab_id) = fresh_tab();
2556 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("only", "b1"));
2557
2558 let events = update(
2559 &mut state,
2560 Command::LayoutInsertNode {
2561 tab_id: tab_id.clone(),
2562 node: leaf_node("added", "b2"),
2563 parent_id: Some("does-not-exist".into()),
2564 index: None,
2565 focus_after: false,
2566 magnify_after: false,
2567 correlation_id: "corr".into(),
2568 },
2569 &ctx(1),
2570 );
2571 assert!(matches!(
2572 &events[0],
2573 Event::Error { code: ErrorCode::InvalidCommand, .. }
2574 ));
2575 let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2577 assert_eq!(root.id, "only");
2578 assert!(root.children.is_empty());
2579 }
2580
2581 #[test]
2582 fn layout_insert_node_with_leaf_parent_id_emits_error() {
2583 let (mut state, tab_id) = fresh_tab();
2586 let mut root = leaf_node("group", "");
2587 root.data = None;
2588 root.children = vec![leaf_node("a", "ba")];
2589 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2590
2591 let events = update(
2592 &mut state,
2593 Command::LayoutInsertNode {
2594 tab_id: tab_id.clone(),
2595 node: leaf_node("b", "bb"),
2596 parent_id: Some("a".into()), index: None,
2598 focus_after: false,
2599 magnify_after: false,
2600 correlation_id: "corr".into(),
2601 },
2602 &ctx(1),
2603 );
2604 assert!(matches!(
2605 &events[0],
2606 Event::Error { code: ErrorCode::InvalidCommand, .. }
2607 ));
2608 }
2609
2610 #[test]
2611 fn layout_insert_node_with_neither_flag_leaves_state_alone() {
2612 let (mut state, tab_id) = fresh_tab();
2616 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2617 let _ = update(
2618 &mut state,
2619 Command::LayoutInsertNode {
2620 tab_id: tab_id.clone(),
2621 node: leaf_node("new", "b1"),
2622 parent_id: None,
2623 index: None,
2624 focus_after: false,
2625 magnify_after: false,
2626 correlation_id: "corr".into(),
2627 },
2628 &ctx(1),
2629 );
2630 assert_eq!(state.tabs[&tab_id].focused_node_id, "prev");
2631 }
2632
2633 #[test]
2634 fn layout_delete_node_on_root_clears_the_tree() {
2635 let (mut state, tab_id) = fresh_tab();
2640 state.tabs.get_mut(&tab_id).unwrap().rootnode =
2641 Some(leaf_node("solitary-root", "b1"));
2642 let events = update(
2643 &mut state,
2644 Command::LayoutDeleteNode {
2645 tab_id: tab_id.clone(),
2646 node_id: "solitary-root".into(),
2647 correlation_id: "corr".into(),
2648 },
2649 &ctx(1),
2650 );
2651 assert!(matches!(&events[0], Event::LayoutNodeDeleted { .. }));
2652 assert!(
2653 state.tabs[&tab_id].rootnode.is_none(),
2654 "root deletion must wipe the tree"
2655 );
2656 }
2657
2658 #[test]
2661 fn layout_delete_node_by_block_resolves_and_carries_new_tree() {
2662 let (mut state, tab_id) = fresh_tab();
2663 let mut root = leaf_node("group", "");
2664 root.data = None;
2665 root.children = vec![
2666 leaf_node("a", "b1"),
2667 leaf_node("b", "b2"),
2668 leaf_node("c", "b3"),
2669 ];
2670 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2671
2672 let events = update(
2673 &mut state,
2674 Command::LayoutDeleteNodeByBlock {
2675 tab_id: tab_id.clone(),
2676 block_id: "b2".into(),
2677 correlation_id: "corr".into(),
2678 },
2679 &ctx(1),
2680 );
2681 match &events[0] {
2682 Event::LayoutNodeDeleted {
2683 node_id,
2684 new_tree,
2685 tree_cleared,
2686 ..
2687 } => {
2688 assert_eq!(node_id, "b", "must resolve block b2 to node b");
2689 assert!(!*tree_cleared, "non-root delete must not claim a clear");
2690 let tree = new_tree.as_ref().expect("non-root delete keeps a tree");
2691 assert!(
2692 crate::backend::layout::find_node_id_by_block(tree, "b2").is_none(),
2693 "deleted block must be gone from the event's tree"
2694 );
2695 assert!(
2696 crate::backend::layout::find_node_id_by_block(tree, "b1").is_some(),
2697 "sibling blocks must survive"
2698 );
2699 assert_eq!(Some(tree), state.tabs[&tab_id].rootnode.as_ref());
2702 }
2703 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
2704 }
2705 }
2706
2707 fn stacked_leaf(id: &str, stack: &[&str], visible: &str) -> agentmux_common::LayoutNode {
2708 let mut leaf = leaf_node(id, visible);
2709 let data = leaf.data.as_mut().unwrap();
2710 data.block_stack = stack.iter().map(|s| s.to_string()).collect();
2711 data.active_block_id = visible.to_string();
2712 leaf
2713 }
2714
2715 fn only_leaf_data(state: &State, tab_id: &str) -> agentmux_common::LayoutNodeData {
2716 state.tabs[tab_id].rootnode.as_ref().unwrap().data.clone().unwrap()
2717 }
2718
2719 fn delete_by_block(state: &mut State, tab_id: &str, block_id: &str) -> Vec<Event> {
2720 update(
2721 state,
2722 Command::LayoutDeleteNodeByBlock {
2723 tab_id: tab_id.to_string(),
2724 block_id: block_id.to_string(),
2725 correlation_id: String::new(),
2726 },
2727 &ctx(1),
2728 )
2729 }
2730
2731 #[test]
2736 fn layout_delete_node_by_block_removes_only_a_background_stack_member() {
2737 let (mut state, tab_id) = fresh_tab();
2738 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["agent", "term"], "agent"));
2739
2740 let events = delete_by_block(&mut state, &tab_id, "term");
2741
2742 assert!(matches!(events[0], Event::LayoutTreeReplaced { slices: None, .. }), "got {:?}", events);
2743 let data = only_leaf_data(&state, &tab_id);
2744 assert_eq!(data.block_stack, vec!["agent".to_string()]);
2745 assert_eq!(data.block_id, "agent", "the visible tab is untouched");
2746 }
2747
2748 #[test]
2749 fn layout_delete_node_by_block_on_the_visible_member_activates_the_right_neighbour() {
2750 let (mut state, tab_id) = fresh_tab();
2751 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b", "c"], "b"));
2752
2753 delete_by_block(&mut state, &tab_id, "b");
2754
2755 let data = only_leaf_data(&state, &tab_id);
2756 assert_eq!(data.block_stack, vec!["a".to_string(), "c".to_string()]);
2757 assert_eq!(data.block_id, "c");
2758 assert_eq!(data.active_block_id, "c");
2759 }
2760
2761 #[test]
2762 fn layout_delete_node_by_block_on_the_rightmost_visible_member_activates_the_new_last() {
2763 let (mut state, tab_id) = fresh_tab();
2764 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b"], "b"));
2765
2766 delete_by_block(&mut state, &tab_id, "b");
2767
2768 let data = only_leaf_data(&state, &tab_id);
2769 assert_eq!(data.block_stack, vec!["a".to_string()]);
2770 assert_eq!(data.block_id, "a");
2771 }
2772
2773 #[test]
2774 fn layout_delete_node_by_block_on_the_last_stack_member_removes_the_pane() {
2775 let (mut state, tab_id) = fresh_tab();
2776 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a"], "a"));
2777
2778 let events = delete_by_block(&mut state, &tab_id, "a");
2779
2780 assert!(matches!(events[0], Event::LayoutNodeDeleted { .. }), "got {:?}", events);
2781 assert!(state.tabs[&tab_id].rootnode.is_none());
2782 }
2783
2784 #[test]
2789 fn create_block_in_stack_creates_and_places_in_one_step() {
2790 let (mut state, tab_id) = fresh_tab();
2791 seed_block(&mut state, &tab_id, "anchor");
2792 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2793
2794 let events = update(
2795 &mut state,
2796 Command::CreateBlockInStack {
2797 tab_id: tab_id.clone(),
2798 target_block_id: "anchor".into(),
2799 meta: serde_json::json!({ "view": "agent" }),
2800 activate: true,
2801 },
2802 &ctx(1),
2803 );
2804 let new_id = events
2805 .iter()
2806 .find_map(|e| match e {
2807 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
2808 _ => None,
2809 })
2810 .expect("block created");
2811 assert!(
2812 events.iter().any(|e| matches!(e, Event::LayoutTreeReplaced { slices: None, .. })),
2813 "placed in the same step: {events:?}"
2814 );
2815 assert!(state.blocks.contains_key(&new_id));
2816 assert!(state.tabs[&tab_id].block_ids.contains(&new_id));
2817 let data = state.tabs[&tab_id].rootnode.as_ref().unwrap().data.clone().unwrap();
2818 assert_eq!(data.block_stack, vec!["anchor".to_string(), new_id.clone()]);
2819 assert_eq!(data.block_id, new_id);
2820 }
2821
2822 #[test]
2823 fn create_block_in_stack_creates_nothing_when_no_pane_holds_the_target() {
2824 let (mut state, tab_id) = fresh_tab();
2825 seed_block(&mut state, &tab_id, "anchor");
2826 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2827 let blocks_before = state.blocks.len();
2828 let tab_blocks_before = state.tabs[&tab_id].block_ids.clone();
2829
2830 let events = update(
2831 &mut state,
2832 Command::CreateBlockInStack {
2833 tab_id: tab_id.clone(),
2834 target_block_id: "missing".into(),
2835 meta: serde_json::Value::Null,
2836 activate: true,
2837 },
2838 &ctx(1),
2839 );
2840 assert!(matches!(events.as_slice(), [Event::Error { .. }]), "got {events:?}");
2841 assert_eq!(state.blocks.len(), blocks_before, "no block without a pane");
2842 assert_eq!(state.tabs[&tab_id].block_ids, tab_blocks_before, "nothing added to the tab");
2843 }
2844
2845 #[test]
2846 fn layout_stack_push_rejects_a_block_from_another_tab() {
2847 let (mut state, tab_id) = fresh_tab();
2848 seed_block(&mut state, &tab_id, "anchor");
2849 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2850 let events = update(
2851 &mut state,
2852 Command::LayoutStackPush {
2853 tab_id: tab_id.clone(),
2854 target_block_id: "anchor".into(),
2855 block_id: "stranger".into(),
2856 activate: true,
2857 correlation_id: String::new(),
2858 },
2859 &ctx(1),
2860 );
2861 assert!(matches!(events.as_slice(), [Event::Error { .. }]), "got {events:?}");
2862 assert!(state.tabs[&tab_id].rootnode.as_ref().unwrap().data.as_ref().unwrap().block_stack.is_empty());
2863 }
2864
2865 #[test]
2866 fn layout_stack_activate_switches_the_visible_tab() {
2867 let (mut state, tab_id) = fresh_tab();
2868 seed_block(&mut state, &tab_id, "a");
2869 seed_block(&mut state, &tab_id, "b");
2870 let mut leaf = leaf_node("pane", "a");
2871 leaf.data.as_mut().unwrap().block_stack = vec!["a".into(), "b".into()];
2872 leaf.data.as_mut().unwrap().active_block_id = "a".into();
2873 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf);
2874
2875 let events = update(
2876 &mut state,
2877 Command::LayoutStackActivate {
2878 tab_id: tab_id.clone(),
2879 block_id: "b".into(),
2880 correlation_id: String::new(),
2881 },
2882 &ctx(1),
2883 );
2884 assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { .. }]), "got {events:?}");
2885 let data = state.tabs[&tab_id].rootnode.as_ref().unwrap().data.clone().unwrap();
2886 assert_eq!((data.block_id.as_str(), data.active_block_id.as_str()), ("b", "b"));
2887 }
2888
2889 #[test]
2892 fn layout_stack_move_reorders_within_the_same_pane() {
2893 let (mut state, tab_id) = fresh_tab();
2894 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b", "c"], "a"));
2895
2896 let events = update(
2897 &mut state,
2898 Command::LayoutStackMove {
2899 tab_id: tab_id.clone(),
2900 block_id: "c".into(),
2901 target_block_id: "a".into(),
2902 position: agentmux_common::StackMovePosition::Before,
2903 activate: false,
2904 correlation_id: String::new(),
2905 },
2906 &ctx(1),
2907 );
2908 assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { slices: None, .. }]), "got {events:?}");
2909 let data = only_leaf_data(&state, &tab_id);
2910 assert_eq!(data.block_stack, vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2911 assert_eq!(data.block_id, "a", "not activated, and reorder alone must not switch the visible tab");
2912 }
2913
2914 #[test]
2915 fn layout_stack_move_moves_a_tab_into_a_different_pane() {
2916 let (mut state, tab_id) = fresh_tab();
2917 seed_block(&mut state, &tab_id, "a");
2918 seed_block(&mut state, &tab_id, "b");
2919 seed_block(&mut state, &tab_id, "c");
2920 let root = agentmux_common::LayoutNode {
2921 id: "root".into(),
2922 children: vec![stacked_leaf("src", &["a", "b"], "a"), leaf_node("dst", "c")],
2923 ..Default::default()
2924 };
2925 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2926
2927 let events = update(
2928 &mut state,
2929 Command::LayoutStackMove {
2930 tab_id: tab_id.clone(),
2931 block_id: "b".into(),
2932 target_block_id: "c".into(),
2933 position: agentmux_common::StackMovePosition::End,
2934 activate: true,
2935 correlation_id: String::new(),
2936 },
2937 &ctx(1),
2938 );
2939 assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { slices: None, .. }]), "got {events:?}");
2940 let tree = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2941 let src = tree.children[0].data.as_ref().unwrap();
2942 assert_eq!(src.block_stack, vec!["a".to_string()], "removed from the source pane");
2943 let dst = tree.children[1].data.as_ref().unwrap();
2944 assert_eq!(dst.block_stack, vec!["c".to_string(), "b".to_string()]);
2945 assert_eq!((dst.block_id.as_str(), dst.active_block_id.as_str()), ("b", "b"));
2946 }
2947
2948 fn lone_src_and_stacked_dst(state: &mut State, tab_id: &str) {
2952 for b in ["a", "c", "d", "z"] {
2953 seed_block(state, tab_id, b);
2954 }
2955 state.tabs.get_mut(tab_id).unwrap().rootnode = Some(agentmux_common::LayoutNode {
2956 id: "root".into(),
2957 children: vec![leaf_node("src", "a"), stacked_leaf("dst", &["c", "d"], "c"), leaf_node("other", "z")],
2958 ..Default::default()
2959 });
2960 }
2961
2962 fn move_lone_a_to_d(state: &mut State, tab_id: &str) -> Vec<Event> {
2963 update(
2964 state,
2965 Command::LayoutStackMove {
2966 tab_id: tab_id.to_string(),
2967 block_id: "a".into(),
2968 target_block_id: "d".into(),
2969 position: agentmux_common::StackMovePosition::End,
2970 activate: true,
2971 correlation_id: String::new(),
2972 },
2973 &ctx(1),
2974 )
2975 }
2976
2977 #[test]
2978 fn layout_stack_move_of_a_panes_only_tab_removes_the_emptied_pane() {
2979 let (mut state, tab_id) = fresh_tab();
2980 lone_src_and_stacked_dst(&mut state, &tab_id);
2981 let blocks_before = state.blocks.len();
2982
2983 let events = move_lone_a_to_d(&mut state, &tab_id);
2984 let tree = state.tabs[&tab_id].rootnode.clone().unwrap();
2985 match events.as_slice() {
2986 [Event::LayoutNodeDeleted { node_id, new_tree, tree_cleared, .. }] => {
2987 assert_eq!(node_id, "src", "the emptied source pane");
2988 assert_eq!(new_tree.as_ref(), Some(&tree), "carries the full post-move tree for persistence");
2989 assert!(!tree_cleared);
2990 }
2991 other => panic!("expected one LayoutNodeDeleted, got {other:?}"),
2992 }
2993 let ids: Vec<&str> = tree.children.iter().map(|c| c.id.as_str()).collect();
2994 assert_eq!(ids, vec!["dst", "other"]);
2995 let dst = tree.children[0].data.as_ref().unwrap();
2996 assert_eq!(dst.block_stack, vec!["c".to_string(), "d".to_string(), "a".to_string()]);
2997 assert_eq!((dst.block_id.as_str(), dst.active_block_id.as_str()), ("a", "a"));
2998 assert_eq!(state.blocks.len(), blocks_before, "a move: no block is deleted");
2999 }
3000
3001 #[test]
3002 fn layout_stack_move_of_a_panes_only_tab_clears_focus_and_magnify_on_the_gone_pane() {
3003 let (mut state, tab_id) = fresh_tab();
3004 lone_src_and_stacked_dst(&mut state, &tab_id);
3005 {
3006 let tab = state.tabs.get_mut(&tab_id).unwrap();
3007 tab.focused_node_id = "src".into();
3008 tab.magnified_node_id = "src".into();
3009 }
3010 let events = move_lone_a_to_d(&mut state, &tab_id);
3011 assert!(
3012 matches!(events.as_slice(), [Event::LayoutNodeDeleted { was_focused: true, was_magnified: true, .. }]),
3013 "got {events:?}"
3014 );
3015 let tab = &state.tabs[&tab_id];
3016 assert!(tab.focused_node_id.is_empty() && tab.magnified_node_id.is_empty());
3017 }
3018
3019 #[test]
3020 fn layout_stack_move_of_a_panes_only_tab_keeps_focus_on_a_pane_that_survives() {
3021 let (mut state, tab_id) = fresh_tab();
3022 lone_src_and_stacked_dst(&mut state, &tab_id);
3023 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "other".into();
3024 let events = move_lone_a_to_d(&mut state, &tab_id);
3025 assert!(
3026 matches!(events.as_slice(), [Event::LayoutNodeDeleted { was_focused: false, was_magnified: false, .. }]),
3027 "got {events:?}"
3028 );
3029 assert_eq!(state.tabs[&tab_id].focused_node_id, "other");
3030 }
3031
3032 #[test]
3033 fn layout_delete_node_by_block_unknown_block_is_silent_noop() {
3034 let (mut state, tab_id) = fresh_tab();
3039 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("only", "b1"));
3040
3041 let events = update(
3042 &mut state,
3043 Command::LayoutDeleteNodeByBlock {
3044 tab_id: tab_id.clone(),
3045 block_id: "not-in-tree".into(),
3046 correlation_id: String::new(),
3047 },
3048 &ctx(1),
3049 );
3050 assert!(events.is_empty(), "unresolved block must be a silent no-op");
3051 assert_eq!(state.tabs[&tab_id].rootnode.as_ref().unwrap().id, "only");
3052 }
3053
3054 #[test]
3055 fn layout_delete_node_by_block_root_orphan_sets_tree_cleared() {
3056 let (mut state, tab_id) = fresh_tab();
3061 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("solo", "b1"));
3062
3063 let events = update(
3064 &mut state,
3065 Command::LayoutDeleteNodeByBlock {
3066 tab_id: tab_id.clone(),
3067 block_id: "b1".into(),
3068 correlation_id: String::new(),
3069 },
3070 &ctx(1),
3071 );
3072 match &events[0] {
3073 Event::LayoutNodeDeleted {
3074 new_tree,
3075 tree_cleared,
3076 ..
3077 } => {
3078 assert!(new_tree.is_none());
3079 assert!(*tree_cleared, "root-orphan delete must mark the clear as real");
3080 }
3081 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3082 }
3083 assert!(state.tabs[&tab_id].rootnode.is_none());
3084 }
3085
3086 #[test]
3087 fn layout_delete_node_clears_magnified_when_target_was_magnified() {
3088 let (mut state, tab_id) = fresh_tab();
3091 let mut root = leaf_node("group", "");
3092 root.data = None;
3093 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3094 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3095 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
3096 let _ = update(
3097 &mut state,
3098 Command::LayoutDeleteNode {
3099 tab_id: tab_id.clone(),
3100 node_id: "a".into(),
3101 correlation_id: "corr".into(),
3102 },
3103 &ctx(1),
3104 );
3105 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
3106 }
3107
3108 #[test]
3109 fn layout_node_deleted_event_carries_was_magnified() {
3110 let (mut state, tab_id) = fresh_tab();
3114 let mut root = leaf_node("group", "");
3115 root.data = None;
3116 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3117 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3118 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
3119
3120 let events = update(
3121 &mut state,
3122 Command::LayoutDeleteNode {
3123 tab_id: tab_id.clone(),
3124 node_id: "a".into(),
3125 correlation_id: "corr".into(),
3126 },
3127 &ctx(1),
3128 );
3129 match &events[0] {
3130 Event::LayoutNodeDeleted { was_magnified, was_focused, .. } => {
3131 assert!(*was_magnified, "was_magnified must be true");
3132 assert!(!*was_focused, "was_focused stays false");
3133 }
3134 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3135 }
3136 }
3137
3138 #[test]
3139 fn layout_delete_node_clears_focused_when_collapse_replaces_parent_id() {
3140 let (mut state, tab_id) = fresh_tab();
3147 let mut group = leaf_node("group-id", "");
3148 group.data = None;
3149 group.children = vec![leaf_node("only-child", "b1"), leaf_node("sibling", "b2")];
3150 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
3151 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "group-id".into();
3155
3156 let events = update(
3157 &mut state,
3158 Command::LayoutDeleteNode {
3159 tab_id: tab_id.clone(),
3160 node_id: "sibling".into(),
3161 correlation_id: "corr".into(),
3162 },
3163 &ctx(1),
3164 );
3165 match &events[0] {
3166 Event::LayoutNodeDeleted { was_focused, .. } => {
3167 assert!(
3168 *was_focused,
3169 "collapse rewrote parent id; reducer must report focus loss"
3170 );
3171 }
3172 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3173 }
3174 assert_eq!(
3175 state.tabs[&tab_id].focused_node_id, "",
3176 "stale focus cleared post-collapse"
3177 );
3178 }
3179
3180 #[test]
3181 fn layout_delete_node_clears_focused_when_target_subtree_contains_focus() {
3182 let (mut state, tab_id) = fresh_tab();
3186 let mut leaf_x = leaf_node("leaf-x", "bx");
3190 leaf_x.data = Some(agentmux_common::LayoutNodeData {
3191 block_id: "bx".into(),
3192 ..Default::default()
3193 });
3194 let mut group_a = leaf_node("group-A", "");
3195 group_a.data = None;
3196 group_a.children = vec![leaf_x, leaf_node("leaf-y", "by")];
3197 let mut root = leaf_node("root-group", "");
3198 root.data = None;
3199 root.children = vec![group_a, leaf_node("leaf-z", "bz")];
3200 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3201 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "leaf-x".into();
3203 state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "leaf-y".into();
3204
3205 let events = update(
3206 &mut state,
3207 Command::LayoutDeleteNode {
3208 tab_id: tab_id.clone(),
3209 node_id: "group-A".into(),
3210 correlation_id: "corr".into(),
3211 },
3212 &ctx(1),
3213 );
3214 match &events[0] {
3215 Event::LayoutNodeDeleted { was_focused, was_magnified, .. } => {
3216 assert!(*was_focused, "descendant focus must be cleared");
3217 assert!(*was_magnified, "descendant magnify must be cleared");
3218 }
3219 other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3220 }
3221 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3222 assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
3223 }
3224
3225 #[test]
3226 fn layout_insert_node_event_echoes_parent_id_and_index() {
3227 let (mut state, tab_id) = fresh_tab();
3232 let mut group = leaf_node("group", "");
3233 group.data = None;
3234 group.children = vec![leaf_node("a", "ba")];
3235 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
3236
3237 let events = update(
3238 &mut state,
3239 Command::LayoutInsertNode {
3240 tab_id: tab_id.clone(),
3241 node: leaf_node("new", "b1"),
3242 parent_id: Some("group".into()),
3243 index: Some(0),
3244 focus_after: false,
3245 magnify_after: false,
3246 correlation_id: "corr".into(),
3247 },
3248 &ctx(1),
3249 );
3250 match &events[0] {
3251 Event::LayoutNodeInserted { parent_id, index, .. } => {
3252 assert_eq!(parent_id.as_deref(), Some("group"));
3253 assert_eq!(*index, Some(0));
3254 }
3255 other => panic!("expected LayoutNodeInserted, got {:?}", other),
3256 }
3257 }
3258
3259 #[test]
3260 fn layout_delete_node_clears_focused_when_target_was_focused() {
3261 let (mut state, tab_id) = fresh_tab();
3262 let mut root = leaf_node("root-group", "");
3264 root.data = None;
3265 root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3266 state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3267 state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "a".into();
3268
3269 let events = update(
3270 &mut state,
3271 Command::LayoutDeleteNode {
3272 tab_id: tab_id.clone(),
3273 node_id: "a".into(),
3274 correlation_id: "corr-del".into(),
3275 },
3276 &ctx(1),
3277 );
3278 assert!(matches!(
3279 &events[0],
3280 Event::LayoutNodeDeleted { was_focused: true, .. }
3281 ));
3282 assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3283 }
3284}