agentmux_srv\reducer/
layout.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9/// Phase E.4 (Option A) — set the focused layout-node id on a tab.
10/// Errors (non-fatal) if the tab is unknown to the reducer; no-op
11/// short-circuit when the value is already current. Empty `node_id`
12/// clears the field. Bumps the version only on real changes so a
13/// burst of identical sets doesn't churn the event stream.
14pub(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
36/// Phase E.4 (Option A) — set the magnified layout-node id on a tab.
37/// Same shape as `handle_set_focused_node`. Empty `node_id` is the
38/// toggle-off / clear case.
39pub(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) -> Vec<Event> {
92    let Some(tab) = state.tabs.get_mut(&tab_id) else {
93        let v = state.bump_version();
94        return vec![Event::Error {
95            code: ErrorCode::InvalidCommand,
96            message: format!("LayoutSetTree: unknown tab {}", tab_id),
97            fatal: false,
98            version: v,
99        }];
100    };
101    tab.rootnode = new_tree.clone();
102    // when the tree is wiped, focused/
103    // magnified ids would point at non-existent nodes. Match
104    // `handle_layout_clear`'s contract for the empty-tree case.
105    if new_tree.is_none() {
106        tab.focused_node_id = String::new();
107        tab.magnified_node_id = String::new();
108    }
109    let v = state.bump_version();
110    vec![Event::LayoutTreeReplaced {
111        tab_id,
112        new_tree,
113        correlation_id,
114        version: v,
115    }]
116}
117
118pub(super) fn handle_layout_insert_node(
119    state: &mut State,
120    tab_id: String,
121    node: agentmux_common::LayoutNode,
122    parent_id: Option<String>,
123    index: Option<usize>,
124    focus_after: bool,
125    magnify_after: bool,
126    correlation_id: String,
127) -> Vec<Event> {
128    let Some(tab) = state.tabs.get_mut(&tab_id) else {
129        let v = state.bump_version();
130        return vec![Event::Error {
131            code: ErrorCode::InvalidCommand,
132            message: format!("LayoutInsertNode: unknown tab {}", tab_id),
133            fatal: false,
134            version: v,
135        }];
136    };
137    // Three insert paths, in priority order:
138    //   1. Empty tree → promote new node to root.
139    //   2. parent_id given → insert under that specific parent at
140    //      `index` (or append if None). If parent_id is given but
141    //      doesn't resolve to a group node, REJECT with
142    //      Event::Error rather than fall back to the heuristic.
143    //      A silent-fallback path is a consistency hole: the
144    //      emitted event would echo the requested parent_id while
145    //      the actual mutation
146    //      went elsewhere, diverging the persist subscriber and
147    //      replay consumers from the reducer.
148    //   3. parent_id None → `findNextInsertLocation` heuristic.
149    let empty_tree = tab.rootnode.is_none();
150    if empty_tree {
151        // Empty-tree promotion must reject any explicit `parent_id`/
152        // `index` for the same divergence reason as the explicit-
153        // parent path below — the event would echo a target the
154        // tree cannot resolve, and a replay consumer or the persist
155        // subscriber would diverge from the reducer. Per
156        // `srv-phase-e4b-formal-spec-2026-05-03.md` §7.1, both
157        // fields must be `None` for empty-tree promote.
158        if parent_id.is_some() || index.is_some() {
159            let v = state.bump_version();
160            return vec![Event::Error {
161                code: ErrorCode::InvalidCommand,
162                message: format!(
163                    "LayoutInsertNode: empty tree cannot honour explicit parent_id={:?} / index={:?} (tab {})",
164                    parent_id, index, tab_id
165                ),
166                fatal: false,
167                version: v,
168            }];
169        }
170        tab.rootnode = Some(node.clone());
171    } else if let Some(pid) = parent_id.as_deref() {
172        let root = tab.rootnode.as_mut().expect("non-empty checked above");
173        match crate::backend::layout::find_node_by_id_mut(root, pid) {
174            Some(parent_node) if parent_node.data.is_none() => {
175                let len = parent_node.children.len();
176                let target = index.map(|i| i.min(len)).unwrap_or(len);
177                parent_node.children.insert(target, node.clone());
178            }
179            Some(_) => {
180                // parent_id resolves to a leaf — can't host children.
181                let v = state.bump_version();
182                return vec![Event::Error {
183                    code: ErrorCode::InvalidCommand,
184                    message: format!(
185                        "LayoutInsertNode: parent_id {:?} is a leaf node, cannot host children (tab {})",
186                        pid, tab_id
187                    ),
188                    fatal: false,
189                    version: v,
190                }];
191            }
192            None => {
193                let v = state.bump_version();
194                return vec![Event::Error {
195                    code: ErrorCode::InvalidCommand,
196                    message: format!(
197                        "LayoutInsertNode: parent_id {:?} not found in tree (tab {})",
198                        pid, tab_id
199                    ),
200                    fatal: false,
201                    version: v,
202                }];
203            }
204        }
205    } else {
206        let root = tab.rootnode.as_mut().expect("non-empty checked above");
207        crate::backend::layout::insert_node(root, node.clone());
208    }
209    // honour focus_after / magnify_after.
210    // The schema documents these as the side effects callers rely on
211    // for "insert + activate" flows; ignoring them desyncs the snapshot
212    // from the event the caller's handler observed.
213    //
214    // a magnified node must also be the
215    // focused one. Without this, a `magnify_after=true,
216    // focus_after=false` insert leaves `focused_node_id` pointing at
217    // the prior pane while `magnified_node_id` points at the new one
218    // — a UI invariant violation (frontend treats magnify-implies-
219    // focus). Treat magnify as implying focus.
220    if focus_after || magnify_after {
221        tab.focused_node_id = node.id.clone();
222    }
223    if magnify_after {
224        tab.magnified_node_id = node.id.clone();
225    }
226    let v = state.bump_version();
227    vec![Event::LayoutNodeInserted {
228        tab_id,
229        node,
230        // pass the command's
231        // parent_id / index through to the event so subscribers see
232        // what the caller asked for, not a hardcoded `None, None`.
233        // The pure helper currently uses the `findNextInsertLocation`
234        // heuristic and ignores these hints — but the event is the
235        // record of what was *requested*; subscribers can correlate
236        // with the resulting tree by inspecting `node` itself.
237        parent_id,
238        index,
239        correlation_id,
240        version: v,
241    }]
242}
243
244pub(super) fn handle_layout_delete_node(
245    state: &mut State,
246    tab_id: String,
247    node_id: String,
248    correlation_id: String,
249) -> Vec<Event> {
250    let Some(tab) = state.tabs.get_mut(&tab_id) else {
251        let v = state.bump_version();
252        return vec![Event::Error {
253            code: ErrorCode::InvalidCommand,
254            message: format!("LayoutDeleteNode: unknown tab {}", tab_id),
255            fatal: false,
256            version: v,
257        }];
258    };
259    // Snapshot pre-delete focus/magnify so we can both detect
260    // direct-target hits AND post-walk for indirect orphaning.
261    let pre_focused = tab.focused_node_id.clone();
262    let pre_magnified = tab.magnified_node_id.clone();
263    let Some(root) = tab.rootnode.as_mut() else {
264        // Empty tree — nothing to delete; idempotent no-op (no event).
265        return Vec::new();
266    };
267    // `backend::layout::delete_node` leaves
268    // root deletion to the caller (returns Ok(()) with the root
269    // unmodified). Detect the root case here and clear the tree
270    // wholesale so the reducer state matches the
271    // `LayoutNodeDeleted` event we emit.
272    if root.id == node_id {
273        tab.rootnode = None;
274    } else if let Err(e) = crate::backend::layout::delete_node(root, &node_id) {
275        let v = state.bump_version();
276        return vec![Event::Error {
277            code: ErrorCode::InvalidCommand,
278            message: format!("LayoutDeleteNode: {} (tab {})", e, tab_id),
279            fatal: false,
280            version: v,
281        }];
282    }
283
284    // Two ways focus/magnify ids can reference nodes that no longer
285    // exist in the tree post-delete; both must be cleared:
286    //   - `delete_recursive` collapse-sole-child rewrites a
287    //        parent's id to the promoted child's id. If
288    //        focused/magnified was the parent's original id, that
289    //        id is gone from the tree even though the same physical
290    //        node remains.
291    //   - Deleting a container removes all descendants. If
292    //        focused/magnified was a descendant, it's gone too.
293    // Direct-target match (`pre_focused == node_id`) doesn't catch
294    // either case. Reconcile by walking the post-delete tree and
295    // clearing any focus/magnify id that no longer resolves.
296    let id_resolves = |id: &str| -> bool {
297        if id.is_empty() {
298            return true;
299        }
300        match tab.rootnode.as_ref() {
301            None => false,
302            Some(root) => crate::backend::layout::find_node_by_id(root, id).is_some(),
303        }
304    };
305    let was_focused = !pre_focused.is_empty() && !id_resolves(&pre_focused);
306    let was_magnified = !pre_magnified.is_empty() && !id_resolves(&pre_magnified);
307    if was_focused {
308        tab.focused_node_id = String::new();
309    }
310    if was_magnified {
311        tab.magnified_node_id = String::new();
312    }
313
314    let v = state.bump_version();
315    vec![Event::LayoutNodeDeleted {
316        tab_id,
317        node_id,
318        was_focused,
319        was_magnified,
320        correlation_id,
321        version: v,
322    }]
323}