Skip to main content

agentmux_srv/backend/layout/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure layout-tree helper functions for the srv E.4.B reducer.
5//!
6//! Phase 4 of docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md.
7//! Ports the 11 pure-ish action handlers from
8//! `frontend/layout/lib/layoutTree.ts` to Rust. These functions take
9//! `&mut LayoutNode` (the tree root) and operation params; they have no
10//! I/O and no side effects. The reducer arms (Phase 5) will call these.
11//!
12//! Test oracle: the 30 tests in `frontend/layout/tests/layoutTree.test.ts`
13//! (shipped in PR #686) — the Rust implementations must produce identical
14//! state transitions for identical inputs.
15
16use agentmux_common::{FlexDirection, LayoutNode, ResizeOp, SplitPosition, StackMovePosition};
17use uuid::Uuid;
18
19// ── Error type ─────────────────────────────────────────────────────────────
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum LayoutError {
23    NodeNotFound { id: String },
24    /// Caller tried to swap a node with itself.
25    SelfSwap,
26    /// Caller tried to swap, move, or magnify the root node.
27    RootCannotBeTarget,
28    /// A resize op had size outside [0.0, 100.0].
29    InvalidSize { node_id: String, size: f32 },
30    /// index_arr was empty or led to a non-existent location.
31    InvalidIndexPath,
32    /// A node failed the leaf-XOR-branch invariant during balance — it has
33    /// both `data` and `children`, or neither. The TS oracle (`validateNode`
34    /// in `layoutNode.ts`) throws "Invalid node" here.
35    InvalidNode { id: String },
36    /// The op targets a minimize-locked node (minimized leaf, slipped header,
37    /// or dissolved column). Minimized is a locked state — see
38    /// `docs/specs/SPEC_LAYOUT_MINIMIZE_LOCKED_STATE_REDESIGN_2026_07_16.md`.
39    NodeLocked { id: String },
40}
41
42impl std::fmt::Display for LayoutError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::NodeNotFound { id } => write!(f, "node not found: {}", id),
46            Self::SelfSwap => write!(f, "cannot swap a node with itself"),
47            Self::RootCannotBeTarget => write!(f, "root node cannot be the target of this operation"),
48            Self::InvalidSize { node_id, size } => write!(f, "invalid size {:.2} for node {}", size, node_id),
49            Self::InvalidIndexPath => write!(f, "index_arr is empty or points to a non-existent location"),
50            Self::InvalidNode { id } => write!(f, "invalid node (leaf-XOR-branch violated): {}", id),
51            Self::NodeLocked { id } => write!(f, "node is minimize-locked: {}", id),
52        }
53    }
54}
55
56// ── Constants ──────────────────────────────────────────────────────────────
57
58/// Maximum children before `insert_node`'s heuristic descends.
59/// Mirrors `frontend/layout/lib/layoutTree.ts::DEFAULT_MAX_CHILDREN`.
60pub const DEFAULT_MAX_CHILDREN: usize = 5;
61
62/// Default flex size for newly-inserted nodes.
63/// Mirrors `frontend/layout/lib/types.ts::DefaultNodeSize`.
64pub const DEFAULT_NODE_SIZE: f32 = 10.0;
65
66// ── Flex-direction utilities ───────────────────────────────────────────────
67
68/// Reverse a flex direction (Row ↔ Column).
69/// Mirrors `frontend/layout/lib/layoutNode.ts::reverseFlexDirection`.
70fn reverse_flex_direction(dir: FlexDirection) -> FlexDirection {
71    match dir {
72        FlexDirection::Row => FlexDirection::Column,
73        FlexDirection::Column => FlexDirection::Row,
74    }
75}
76
77/// If `node` is a leaf (has `data`), promote it to a group by wrapping its
78/// data in an intermediate child. Mirrors TypeScript `addIntermediateNode`
79/// in `frontend/layout/lib/layoutTree.ts`.
80///
81/// After this call:
82/// - `node.data` is `None` and `node.children` is non-empty.
83/// - The intermediate child receives the node's ORIGINAL ID so any
84///   frontend reference (e.g. `focusedNodeId`) still points at the
85///   leaf data after promotion.
86/// - The group wrapper gets a fresh UUID, and its flex direction is
87///   the reverse of the original leaf's so the new sibling axis is
88///   perpendicular to the parent's (matches TS layout semantics).
89///
90/// No-op when `node` is already a group (no `data`).
91fn ensure_group_node(node: &mut LayoutNode) {
92    if node.data.is_some() {
93        let old_id = node.id.clone();
94        let old_flex = node.flex_direction;
95        let intermediate = LayoutNode {
96            id: old_id,
97            flex_direction: reverse_flex_direction(old_flex),
98            size: DEFAULT_NODE_SIZE,
99            data: node.data.take(),
100            // Move `extra` (forward-compat catch-all from #688/#689) to the
101            // intermediate so unknown fields the frontend wrote to the leaf
102            // travel with the data, not stay on the new group wrapper.
103            // Mirrors the same transfer in `delete_recursive` line below.
104            extra: std::mem::take(&mut node.extra),
105            ..Default::default()
106        };
107        node.id = Uuid::new_v4().to_string();
108        node.children.push(intermediate);
109    }
110}
111
112// ── Tree traversal ─────────────────────────────────────────────────────────
113
114/// Find a node by id (immutable).
115pub fn find_node_by_id<'a>(tree: &'a LayoutNode, id: &str) -> Option<&'a LayoutNode> {
116    if tree.id == id {
117        return Some(tree);
118    }
119    for child in &tree.children {
120        if let Some(found) = find_node_by_id(child, id) {
121            return Some(found);
122        }
123    }
124    None
125}
126
127/// Find a node by id (mutable).
128pub fn find_node_by_id_mut<'a>(tree: &'a mut LayoutNode, id: &str) -> Option<&'a mut LayoutNode> {
129    if tree.id == id {
130        return Some(tree);
131    }
132    for child in &mut tree.children {
133        if let Some(found) = find_node_by_id_mut(child, id) {
134            return Some(found);
135        }
136    }
137    None
138}
139
140/// Find the id of the (first) leaf node whose `data.block_id` matches
141/// `block_id`. SPEC_864 site #6 — lets `LayoutDeleteNodeByBlock` resolve a
142/// block to its layout node inside the reducer arm (the reducer owns the
143/// tree; callers like the `delete_block` saga only know the block id).
144/// Blocks appear at most once in a well-formed tree; first match wins.
145#[cfg(test)]
146pub fn find_node_id_by_block(tree: &LayoutNode, block_id: &str) -> Option<String> {
147    if tree
148        .data
149        .as_ref()
150        .is_some_and(|d| d.block_id == block_id)
151    {
152        return Some(tree.id.clone());
153    }
154    for child in &tree.children {
155        if let Some(found) = find_node_id_by_block(child, block_id) {
156            return Some(found);
157        }
158    }
159    None
160}
161
162/// A leaf's members: its `block_stack`, or `[block_id]` for a leaf with no
163/// stack (mirrors the frontend's `effectiveStack`, `layoutStack.ts`).
164pub fn leaf_members(data: &agentmux_common::LayoutNodeData) -> Vec<String> {
165    if data.block_stack.is_empty() {
166        vec![data.block_id.clone()]
167    } else {
168        data.block_stack.clone()
169    }
170}
171
172/// Find the (first) leaf that has `block_id` as its visible block OR as any
173/// member of its `block_stack`. Unlike [`find_node_id_by_block`], which only
174/// matches the visible block, this finds the pane a background tab lives in.
175/// Returns the leaf node itself.
176pub fn find_leaf_containing_block<'a>(tree: &'a LayoutNode, block_id: &str) -> Option<&'a LayoutNode> {
177    if let Some(d) = tree.data.as_ref() {
178        if d.block_id == block_id || d.block_stack.iter().any(|b| b == block_id) {
179            return Some(tree);
180        }
181    }
182    tree.children
183        .iter()
184        .find_map(|child| find_leaf_containing_block(child, block_id))
185}
186
187/// Which tab becomes visible once the one at `removed_idx` of the ORIGINAL
188/// stack is gone: the nearest eligible member to its right, else the nearest
189/// to its left. `remaining` is the stack with it removed; `eligible` filters
190/// (e.g. "still exists"). The one rule every removal path uses — frontend
191/// `removeMemberFromStack` mirrors it (`frontend/layout/lib/stackMembers.ts`).
192/// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.6.
193pub fn next_visible_member(
194    remaining: &[String],
195    removed_idx: usize,
196    eligible: impl Fn(&String) -> bool,
197) -> Option<String> {
198    let split = removed_idx.min(remaining.len());
199    remaining[split..]
200        .iter()
201        .find(|m| eligible(m))
202        .or_else(|| remaining[..split].iter().rev().find(|m| eligible(m)))
203        .cloned()
204}
205
206/// Add `block_id` to the tab stack of the pane holding `target_block_id`
207/// (as its visible tab or a background member). A pane with no stack yet
208/// becomes a two-member stack. Already a member → only (re)activated if
209/// asked. Returns false when no pane holds `target_block_id`.
210/// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2 (`LayoutStackPush`).
211pub fn push_stack_member(tree: &mut LayoutNode, target_block_id: &str, block_id: &str, activate: bool) -> bool {
212    if let Some(data) = tree.data.as_mut() {
213        if data.block_id == target_block_id || data.block_stack.iter().any(|b| b == target_block_id) {
214            if data.block_stack.is_empty() {
215                data.block_stack = vec![data.block_id.clone()];
216                data.active_block_id = data.block_id.clone();
217            }
218            if !data.block_stack.iter().any(|b| b == block_id) {
219                data.block_stack.push(block_id.to_string());
220            }
221            if activate {
222                data.block_id = block_id.to_string();
223                data.active_block_id = block_id.to_string();
224            }
225            return true;
226        }
227    }
228    tree.children
229        .iter_mut()
230        .any(|child| push_stack_member(child, target_block_id, block_id, activate))
231}
232
233/// Make `block_id` the visible tab of the pane whose stack holds it.
234/// Returns false when no pane's stack holds it.
235/// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2 (`LayoutStackActivate`).
236pub fn activate_stack_member(tree: &mut LayoutNode, block_id: &str) -> bool {
237    if let Some(data) = tree.data.as_mut() {
238        if leaf_members(data).iter().any(|m| m == block_id) {
239            data.block_id = block_id.to_string();
240            if !data.block_stack.is_empty() {
241                data.active_block_id = block_id.to_string();
242            }
243            return true;
244        }
245    }
246    tree.children.iter_mut().any(|child| activate_stack_member(child, block_id))
247}
248
249/// Remove `block_id` from the stack of the leaf holding it, WITHOUT removing
250/// the leaf, when that leaf has other members. If it was the visible tab, the
251/// right-hand neighbour becomes visible (the new last member when it was
252/// rightmost) — the same rule as the frontend's `removeMemberFromStack`
253/// (`frontend/layout/lib/stackMembers.ts`). Returns false and changes nothing
254/// when `block_id` is in no leaf or is its leaf's only member (removing it
255/// means removing the leaf — the caller's job).
256/// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.4.
257pub fn remove_stack_member(tree: &mut LayoutNode, block_id: &str) -> bool {
258    if let Some(data) = tree.data.as_mut() {
259        let members = leaf_members(data);
260        if let Some(idx) = members.iter().position(|m| m == block_id) {
261            if members.len() <= 1 {
262                return false;
263            }
264            let next: Vec<String> = members.into_iter().filter(|m| m != block_id).collect();
265            if data.block_id == block_id || data.active_block_id == block_id {
266                let visible = next_visible_member(&next, idx, |_| true).expect("next is non-empty");
267                data.block_id = visible.clone();
268                data.active_block_id = visible;
269            }
270            data.block_stack = next;
271            return true;
272        }
273    }
274    tree.children
275        .iter_mut()
276        .any(|child| remove_stack_member(child, block_id))
277}
278
279/// Move `block_id` to `position` relative to `target_block_id`: a same-leaf
280/// reorder when both are already members of one leaf's stack, otherwise a
281/// cross-leaf move (removed from its source leaf via [`remove_stack_member`],
282/// placed into `target_block_id`'s leaf via a position-aware push). Returns
283/// `false`, leaving the tree untouched, when either block doesn't exist in
284/// the tree.
285///
286/// When `block_id` is its source leaf's ONLY member, the cross-leaf move
287/// empties that pane, so the source leaf is deleted from the tree (with the
288/// usual single-child-parent collapse, see [`delete_node`]). This is a move:
289/// the block itself lives on in the destination.
290/// [`source_leaf_emptied_by_move`] tells a caller in advance whether this
291/// will happen, so it can reconcile focus/magnify that pointed at that leaf.
292/// SPEC_PANE_TAB_DRAG_LANDING_FLASH_AND_LAST_TAB_CLOSE_2026_09_24.md §4.3.
293///
294/// Deliberately NOT a same-leaf-and-cross-leaf-both composition of
295/// `remove_stack_member` + a push: `remove_stack_member`'s neighbour
296/// reassignment assumes the member is actually leaving its leaf, and wrongly
297/// switches the visible tab if applied to a same-leaf reorder of the
298/// currently-visible member (see
299/// `move_stack_member_reorder_does_not_change_which_tab_is_visible` in
300/// `tests.rs`). The same-leaf branch below never touches
301/// `block_id`/`active_block_id` unless `activate` is explicitly set.
302///
303/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1.
304pub fn move_stack_member(
305    tree: &mut LayoutNode,
306    block_id: &str,
307    target_block_id: &str,
308    position: StackMovePosition,
309    activate: bool,
310) -> bool {
311    if block_id == target_block_id {
312        // Not a real move — nothing to reorder against — but still confirm
313        // the block actually exists (a bogus self-target must not report
314        // success), and still honor `activate` exactly like every other
315        // branch below does (ReAgent P1 on PR #3441: this used to
316        // short-circuit before either check).
317        if find_leaf_containing_block(tree, block_id).is_none() {
318            return false;
319        }
320        return if activate { activate_stack_member(tree, block_id) } else { true };
321    }
322    let Some(source_leaf) = find_leaf_containing_block(tree, block_id) else {
323        return false;
324    };
325    let source_node_id = source_leaf.id.clone();
326    let source_members = leaf_members(source_leaf.data.as_ref().expect("leaf has data"));
327    if source_members.iter().any(|m| m == target_block_id) {
328        return reorder_within_leaf(tree, block_id, target_block_id, position, activate);
329    }
330    // Validate the destination exists BEFORE mutating anything — otherwise
331    // a bad `target_block_id` would still remove `block_id` from its source
332    // leaf and then fail to place it anywhere, corrupting the tree.
333    if find_leaf_containing_block(tree, target_block_id).is_none() {
334        return false;
335    }
336    if source_members.len() <= 1 {
337        // The pane's only tab: the move empties it, so the pane goes. Place
338        // the block first (located by block id, which survives the collapse
339        // below), then delete the source leaf by node id — both inside this
340        // one `&mut` edit, so no caller ever sees the block in two leaves or
341        // in none. The source can't be the root: the destination is a
342        // different leaf in the same tree, so the root has ≥2 leaves under it.
343        if tree.id == source_node_id {
344            return false;
345        }
346        if !push_stack_member_at(tree, target_block_id, block_id, position, activate) {
347            return false;
348        }
349        return delete_node(tree, &source_node_id).is_ok();
350    }
351    if !remove_stack_member(tree, block_id) {
352        return false;
353    }
354    push_stack_member_at(tree, target_block_id, block_id, position, activate)
355}
356
357/// The node id of `block_id`'s leaf, when `move_stack_member(tree, block_id,
358/// target_block_id, ..)` would delete that leaf: `block_id` is its only
359/// member and `target_block_id` lives in a different leaf. `None` for every
360/// other move (reorder, multi-member source, missing blocks, self-target).
361pub fn source_leaf_emptied_by_move(tree: &LayoutNode, block_id: &str, target_block_id: &str) -> Option<String> {
362    if block_id == target_block_id {
363        return None;
364    }
365    let source = find_leaf_containing_block(tree, block_id)?;
366    let members = leaf_members(source.data.as_ref()?);
367    if members.len() != 1 || source.id == tree.id {
368        return None;
369    }
370    find_leaf_containing_block(tree, target_block_id)
371        .filter(|target| target.id != source.id)
372        .map(|_| source.id.clone())
373}
374
375/// Splice `block_id` to `position` relative to `target_block_id` within the
376/// SAME leaf's stack. Both must already be members of that leaf (checked by
377/// the caller, `move_stack_member`). Only touches `block_id`/`active_block_id`
378/// when `activate` is set — a reorder alone never changes which member is
379/// visible.
380fn reorder_within_leaf(
381    tree: &mut LayoutNode,
382    block_id: &str,
383    target_block_id: &str,
384    position: StackMovePosition,
385    activate: bool,
386) -> bool {
387    if let Some(data) = tree.data.as_mut() {
388        let members = leaf_members(data);
389        if members.iter().any(|m| m == block_id) && members.iter().any(|m| m == target_block_id) {
390            let mut next: Vec<String> = members.into_iter().filter(|m| m != block_id).collect();
391            let target_idx = next.iter().position(|m| m == target_block_id).expect("target survives removal");
392            let insert_at = match position {
393                StackMovePosition::Before => target_idx,
394                StackMovePosition::After => target_idx + 1,
395                StackMovePosition::End => next.len(),
396            };
397            next.insert(insert_at, block_id.to_string());
398            data.block_stack = next;
399            if activate {
400                data.block_id = block_id.to_string();
401                data.active_block_id = block_id.to_string();
402            }
403            return true;
404        }
405    }
406    tree.children
407        .iter_mut()
408        .any(|child| reorder_within_leaf(child, block_id, target_block_id, position, activate))
409}
410
411/// Position-aware variant of [`push_stack_member`]: adds `block_id` to the
412/// stack of the leaf holding `target_block_id`, at `position` relative to
413/// `target_block_id` instead of always appending. Promotes a single-block
414/// leaf (empty `block_stack`) into a real stack first, exactly like
415/// `push_stack_member` does.
416fn push_stack_member_at(
417    tree: &mut LayoutNode,
418    target_block_id: &str,
419    block_id: &str,
420    position: StackMovePosition,
421    activate: bool,
422) -> bool {
423    if let Some(data) = tree.data.as_mut() {
424        if data.block_id == target_block_id || data.block_stack.iter().any(|b| b == target_block_id) {
425            if data.block_stack.is_empty() {
426                data.block_stack = vec![data.block_id.clone()];
427                data.active_block_id = data.block_id.clone();
428            }
429            if !data.block_stack.iter().any(|b| b == block_id) {
430                let target_idx = data.block_stack.iter().position(|b| b == target_block_id).expect("target present");
431                let insert_at = match position {
432                    StackMovePosition::Before => target_idx,
433                    StackMovePosition::After => target_idx + 1,
434                    StackMovePosition::End => data.block_stack.len(),
435                };
436                data.block_stack.insert(insert_at, block_id.to_string());
437            }
438            if activate {
439                data.block_id = block_id.to_string();
440                data.active_block_id = block_id.to_string();
441            }
442            return true;
443        }
444    }
445    tree.children
446        .iter_mut()
447        .any(|child| push_stack_member_at(child, target_block_id, block_id, position, activate))
448}
449
450/// Removes every leaf whose `data.block_id` is not in `live_block_ids`,
451/// using the same collapse semantics as an explicit user delete
452/// (`delete_node`'s single-child-parent promotion). Returns the number of
453/// leaves pruned (0 = tree was already clean — the common case, so this is
454/// cheap to call unconditionally on every write).
455///
456/// This is the reducer's referential-integrity enforcement for `db_layout`,
457/// added after
458/// `docs/investigations/INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md`
459/// found that a single point-fix (notifying the frontend on one specific
460/// delete path) only closes the one mechanism it targets — any other
461/// current or future caller that writes a stale/bad tree without going
462/// through that same notification channel can still leave a dangling
463/// `blockId` behind. Calling this at every layout-tree write site in the
464/// reducer (the single writer of `db_layout` since SPEC_864) makes "no
465/// layout leaf ever references a nonexistent block" an unconditional
466/// invariant of the write path itself, not a property individual callers
467/// have to remember to uphold — the same shape as WRR (Window Reality
468/// Reconciliation, `crates/cef/src/wrr/`) enforcing "no window/pool
469/// drift" at the point where reality is observed, rather than trusting
470/// every caller to keep the model in sync.
471///
472/// Re-scans the tree fresh after each removal rather than working off a
473/// pre-computed list of dangling node ids, since `delete_node`'s
474/// single-child collapse can rewrite a surviving parent's id out from
475/// under a stale id — see the comment on `delete_recursive`. Terminates
476/// because each iteration strictly shrinks the tree (bounded by leaf
477/// count).
478pub fn prune_dangling_block_refs(
479    root: &mut Option<LayoutNode>,
480    live_block_ids: &std::collections::HashSet<String>,
481) -> usize {
482    let mut pruned = 0;
483    // In-pane tabs: if a leaf's ACTIVE block went dangling through some
484    // path other than `closeBlockInStack` (which always reactivates a live
485    // neighbor before removing a block), but a live sibling still sits in
486    // `block_stack`, reactivate that sibling in place rather than letting
487    // the whole-leaf scan below delete the entire leaf — that would
488    // silently discard the still-live sibling blocks along with it. MUST
489    // run before the whole-leaf loop so that loop sees the repaired
490    // `block_id`. Leaves with no live sibling are left untouched here —
491    // `block_id` still points at the dangling id, so the loop below
492    // correctly identifies and deletes the (genuinely fully-dead) leaf.
493    if let Some(tree) = root.as_mut() {
494        pruned += reactivate_dangling_active_stack_members(tree, live_block_ids);
495    }
496    loop {
497        let Some(tree) = root.as_ref() else { break };
498        let Some(dangling_id) = first_dangling_leaf_id(tree, live_block_ids) else { break };
499        pruned += 1;
500        if tree.id == dangling_id {
501            // Root is itself the dangling leaf (single-block tab) —
502            // delete_node leaves root deletion to the caller.
503            *root = None;
504            continue;
505        }
506        if let Some(t) = root.as_mut() {
507            // A `NodeNotFound` here would mean the id vanished via this
508            // same loop's prior collapse without our fresh re-scan
509            // catching it first — shouldn't happen given the re-scan, but
510            // isn't a correctness problem either way: the node is gone,
511            // which is the outcome we wanted.
512            let _ = delete_node(t, &dangling_id);
513        }
514    }
515    // In-pane tabs (SPEC_PANE_TAB_STRIP_AGENT_TERMINAL_2026_07_20.md §4.3):
516    // a surviving leaf's ACTIVE block (`data.block_id`) is already
517    // guaranteed live by the loop above, but a non-active `block_stack`
518    // member's block can independently be deleted without ever making the
519    // leaf itself dangling — the leaf still renders fine via its active
520    // member. Without this pass those ids would linger in `block_stack`
521    // forever, so a later tab-switch could try to activate a block that no
522    // longer exists.
523    if let Some(tree) = root.as_mut() {
524        pruned += prune_dangling_stack_members(tree, live_block_ids);
525    }
526    pruned
527}
528
529/// Recursively repair leaves whose ACTIVE block (`data.block_id`) is
530/// dangling but a live sibling still exists in `block_stack`: drop the
531/// dead entry from the stack and reactivate the first remaining live
532/// member (`block_id`/`active_block_id` both updated). Leaves with no live
533/// sibling are left with `block_id` still pointing at the dangling id —
534/// the caller's whole-leaf pass then correctly deletes them, same as
535/// before this function existed. Returns the number of leaves repaired.
536fn reactivate_dangling_active_stack_members(
537    node: &mut LayoutNode,
538    live_block_ids: &std::collections::HashSet<String>,
539) -> usize {
540    let mut repaired = 0;
541    if let Some(data) = node.data.as_mut() {
542        if !data.block_stack.is_empty() && !live_block_ids.contains(&data.block_id) {
543            // Drop the dangling active member from the stack — it's gone
544            // either way, whether or not a live sibling survives it.
545            // Its replacement follows the same right-hand-neighbour rule as
546            // every other removal (`next_visible_member`) — this used to pick
547            // the FIRST live member, disagreeing with the frontend.
548            let dead_idx = data.block_stack.iter().position(|id| id == &data.block_id).unwrap_or(0);
549            data.block_stack.retain(|id| id != &data.block_id);
550            if let Some(replacement) =
551                next_visible_member(&data.block_stack, dead_idx, |id| live_block_ids.contains(id))
552            {
553                data.block_id = replacement.clone();
554                data.active_block_id = replacement;
555                repaired += 1;
556            }
557            // else: block_id is untouched (still the dangling id) — no live
558            // sibling to fall back to, so this leaf is genuinely fully dead.
559        }
560    }
561    for child in &mut node.children {
562        repaired += reactivate_dangling_active_stack_members(child, live_block_ids);
563    }
564    repaired
565}
566
567/// Recursively drop dead entries from every leaf's `block_stack`. The
568/// active member (`data.block_id`) is never removed here — by the time
569/// this runs, the caller's whole-leaf pruning loop has already guaranteed
570/// it's live. Returns the number of stack entries removed.
571fn prune_dangling_stack_members(
572    node: &mut LayoutNode,
573    live_block_ids: &std::collections::HashSet<String>,
574) -> usize {
575    let mut removed = 0;
576    if let Some(data) = node.data.as_mut() {
577        if !data.block_stack.is_empty() {
578            let before = data.block_stack.len();
579            data.block_stack
580                .retain(|id| id == &data.block_id || live_block_ids.contains(id));
581            removed += before - data.block_stack.len();
582        }
583    }
584    for child in &mut node.children {
585        removed += prune_dangling_stack_members(child, live_block_ids);
586    }
587    removed
588}
589
590/// A minimize-locked node: a minimized leaf (`minimizedSize`), a slipped
591/// header (`slipMinimize`), or a dissolved column (`columnDissolve`). The
592/// fields are written by the frontend minimize subsystem and round-trip
593/// through the untyped `extra` catch-all on this side. Mirrors
594/// `isNodeLocked` in `frontend/layout/lib/layoutMinimize.ts`.
595pub fn is_node_locked(node: &LayoutNode) -> bool {
596    // Current model: the `minimized` display-mode flag (geometry derived at
597    // render on the frontend; stored sizes never touched). The remaining keys
598    // are legacy markers from the pre-display-mode model, recognized until
599    // persisted trees are migrated by the frontend's `rebuildMinimizedSet`.
600    node.extra
601        .get("minimized")
602        .and_then(|v| v.as_bool())
603        .unwrap_or(false)
604        || node.extra.contains_key("minimizedSize")
605        || node.extra.contains_key("slipMinimize")
606        || node.extra.contains_key("columnDissolve")
607}
608
609/// True when a node renders as minimized: a locked leaf, or a branch whose
610/// every child is effectively minimized (a stacked chip strip). Mirrors
611/// `isEffectivelyMinimized` in `frontend/layout/lib/layoutInvariants.ts`.
612/// Reducer guards use THIS (not `is_node_locked`): a fully-collapsed branch
613/// has no branch-level marker in the display-mode model, but its stored size
614/// becomes load-bearing again the moment a child restores — so it must be
615/// just as untargetable as a minimized leaf.
616pub fn is_effectively_minimized(node: &LayoutNode) -> bool {
617    if node.children.is_empty() {
618        return is_node_locked(node);
619    }
620    node.children.iter().all(is_effectively_minimized)
621}
622
623/// Write-point enforcement of the minimize lock (the invariant-at-the-write
624/// companion to `prune_dangling_block_refs`, per
625/// `SPEC_LAYOUT_MINIMIZE_LOCKED_STATE_REDESIGN_2026_07_16.md`): snap every
626/// locked node's `size` back to its recorded `minimizedLockedSize`,
627/// returning the delta to the nearest unlocked sibling (next preferred,
628/// then previous) so the parent's flex-unit budget is conserved. It no
629/// longer has a frontend twin: since minimize became a display mode (#2197)
630/// the frontend drops `minimizedLockedSize` when it loads a tree
631/// (`rebuildMinimizedSet` in `frontend/layout/lib/layoutMinimize.ts`), so
632/// only a tree that pass has not migrated yet carries a size to snap to.
633/// Returns the number of snapped nodes (0 = tree already honored the locks
634/// — the common case, cheap to call unconditionally on every write).
635///
636/// If every sibling is locked (inside a dissolved column) the delta is
637/// dropped: flex sizes are relative within the parent, so the locked
638/// nodes' shares stay proportionally correct.
639pub fn enforce_minimized_locks(root: &mut Option<LayoutNode>) -> usize {
640    fn walk(node: &mut LayoutNode) -> usize {
641        let mut snapped = 0;
642        let locked_flags: Vec<bool> = node.children.iter().map(is_node_locked).collect();
643        for i in 0..node.children.len() {
644            let locked_size = node.children[i]
645                .extra
646                .get("minimizedLockedSize")
647                .and_then(|v| v.as_f64())
648                .map(|v| v as f32);
649            if let (true, Some(locked_size)) = (locked_flags[i], locked_size) {
650                let delta = node.children[i].size - locked_size;
651                if delta.abs() > 1e-4 {
652                    node.children[i].size = locked_size;
653                    let beneficiary = (i + 1..node.children.len())
654                        .find(|&j| !locked_flags[j])
655                        .or_else(|| (0..i).rev().find(|&j| !locked_flags[j]));
656                    if let Some(j) = beneficiary {
657                        // Floor at 1 flex unit (same floor as the slip/
658                        // undissolve restore paths): a tampered size BELOW
659                        // the lock makes the delta negative, and the
660                        // repayment must not drive the beneficiary's size
661                        // to zero or negative.
662                        node.children[j].size = (node.children[j].size + delta).max(1.0);
663                    }
664                    snapped += 1;
665                }
666            }
667        }
668        for child in &mut node.children {
669            snapped += walk(child);
670        }
671        snapped
672    }
673    root.as_mut().map_or(0, walk)
674}
675
676/// Layout doctor — structural invariant validation, mirroring
677/// `validateLayoutInvariants` in `frontend/layout/lib/layoutInvariants.ts`
678/// (keep the check lists in sync). Returns human-readable violation strings;
679/// empty = healthy. Never mutates. Reducer choke points call this after
680/// prune/enforce and `tracing::error!` any findings, so a corrupted tree is
681/// attributed to the write that produced it instead of being reconstructed
682/// later from db_layout archaeology (issue #2179).
683pub fn validate_layout_invariants(root: &Option<LayoutNode>) -> Vec<String> {
684    let mut violations = Vec::new();
685    fn short(id: &str) -> &str {
686        &id[..id.len().min(8)]
687    }
688    fn walk(node: &LayoutNode, is_root: bool, violations: &mut Vec<String>) {
689        let is_branch = !node.children.is_empty();
690        let is_leaf = node.data.is_some();
691        let has = |k: &str| node.extra.contains_key(k);
692
693        // I1 — leaf XOR branch.
694        if is_branch == is_leaf {
695            violations.push(format!(
696                "LEAF_XOR_BRANCH @ {}: data {}, children {}",
697                short(&node.id),
698                if is_leaf { "present" } else { "absent" },
699                if is_branch { "present" } else { "absent" }
700            ));
701        }
702        // I2 — the `minimized` flag and the legacy minimizedSize/slipMinimize
703        // markers are leaf-only.
704        if is_branch && (has("minimized") || has("minimizedSize") || has("slipMinimize")) {
705            violations.push(format!(
706                "MIN_MARKER_ON_BRANCH @ {}: branch has {}",
707                short(&node.id),
708                if has("minimized") {
709                    "minimized"
710                } else if has("minimizedSize") {
711                    "minimizedSize"
712                } else {
713                    "slipMinimize"
714                }
715            ));
716        }
717        // I3 — columnDissolve is branch-only.
718        if !is_branch && has("columnDissolve") {
719            violations.push(format!("DISSOLVE_ON_LEAF @ {}", short(&node.id)));
720        }
721        // I4 — a dissolved column must stack vertically (#2176 signature).
722        if has("columnDissolve") && node.flex_direction != FlexDirection::Column {
723            violations.push(format!(
724                "DISSOLVED_NOT_COLUMN @ {}: flex_direction={:?}",
725                short(&node.id),
726                node.flex_direction
727            ));
728        }
729        // I5 — every child of a dissolved column is itself locked.
730        if has("columnDissolve") {
731            for c in &node.children {
732                if !is_node_locked(c) {
733                    violations.push(format!(
734                        "DISSOLVED_CHILD_UNLOCKED @ {}: inside dissolved column {}",
735                        short(&c.id),
736                        short(&node.id)
737                    ));
738                }
739            }
740        }
741        let locked_size = node.extra.get("minimizedLockedSize").and_then(|v| v.as_f64());
742        // I6 — a locked node's size honors its recorded lock (#2180).
743        if is_node_locked(node) {
744            if let Some(ls) = locked_size {
745                if (node.size - ls as f32).abs() > 1e-4 {
746                    violations.push(format!(
747                        "LOCK_SIZE_MISMATCH @ {}: size={} locked={}",
748                        short(&node.id),
749                        node.size,
750                        ls
751                    ));
752                }
753            }
754        } else if locked_size.is_some() {
755            // I7 — minimizedLockedSize must not outlive its lock marker.
756            violations.push(format!("ORPHAN_LOCKED_SIZE @ {}", short(&node.id)));
757        }
758        // I8 — sizes are positive.
759        if !is_root && !(node.size > 0.0) {
760            violations.push(format!("NONPOSITIVE_SIZE @ {}: size={}", short(&node.id), node.size));
761        }
762        // I10 — a pane's tab stack is consistent: the visible block is a
763        // member, `active_block_id` agrees with it, and no block appears
764        // twice (SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.1, I2).
765        if let Some(data) = node.data.as_ref().filter(|d| !d.block_stack.is_empty()) {
766            if !data.block_stack.contains(&data.block_id) {
767                violations.push(format!("STACK_VISIBLE_NOT_MEMBER @ {}", short(&node.id)));
768            }
769            if !data.active_block_id.is_empty() && data.active_block_id != data.block_id {
770                violations.push(format!("STACK_ACTIVE_MISMATCH @ {}", short(&node.id)));
771            }
772            let unique: std::collections::HashSet<&String> = data.block_stack.iter().collect();
773            if unique.len() != data.block_stack.len() {
774                violations.push(format!("STACK_DUPLICATE_MEMBER @ {}", short(&node.id)));
775            }
776        }
777        for c in &node.children {
778            walk(c, false, violations);
779        }
780    }
781    if let Some(tree) = root {
782        walk(tree, true, &mut violations);
783        // I9 — at least one pane stays expanded. A tree whose every leaf is
784        // minimize-locked is an all-headers window with nothing restorable
785        // in view; the frontend's minimize toggle guards against producing
786        // this (`countExpandedLeaves` in layoutMinimize.ts).
787        fn count_leaves(node: &LayoutNode, leaves: &mut usize, expanded: &mut usize) {
788            if node.children.is_empty() {
789                if node.data.is_some() {
790                    *leaves += 1;
791                    if !is_node_locked(node) {
792                        *expanded += 1;
793                    }
794                }
795                return;
796            }
797            for c in &node.children {
798                count_leaves(c, leaves, expanded);
799            }
800        }
801        let (mut leaves, mut expanded) = (0usize, 0usize);
802        count_leaves(tree, &mut leaves, &mut expanded);
803        if leaves > 0 && expanded == 0 {
804            violations.push(format!(
805                "ALL_LEAVES_LOCKED @ {}: all {} leaves are minimize-locked; no expanded pane remains",
806                &tree.id[..tree.id.len().min(8)],
807                leaves
808            ));
809        }
810    }
811    violations
812}
813
814/// First leaf (depth-first) whose `data.block_id` is set but not present in
815/// `live_block_ids`. `data.block_id` is only ever non-empty on leaves —
816/// container/group nodes have `data: None` (see `ensure_group_node`).
817fn first_dangling_leaf_id(
818    node: &LayoutNode,
819    live_block_ids: &std::collections::HashSet<String>,
820) -> Option<String> {
821    if let Some(data) = &node.data {
822        if !data.block_id.is_empty() && !live_block_ids.contains(&data.block_id) {
823            return Some(node.id.clone());
824        }
825    }
826    for child in &node.children {
827        if let Some(found) = first_dangling_leaf_id(child, live_block_ids) {
828            return Some(found);
829        }
830    }
831    None
832}
833
834// ── Insert-location heuristic ──────────────────────────────────────────────
835
836/// Candidate for insertion location, collected during tree traversal.
837struct InsertCandidate<'a> {
838    node: &'a LayoutNode,
839    index: usize,
840    depth: usize,
841}
842
843/// Find the best insertion location using the TypeScript scoring heuristic
844/// (`Math.pow(depth, index + maxChildren)`). Collects all candidates, then
845/// sorts by ascending score. Mirrors `findNextInsertLocation` in
846/// `frontend/layout/lib/layoutNode.ts`.
847fn find_next_insert_location(
848    tree: &LayoutNode,
849    max_children: usize,
850) -> (&LayoutNode, usize) {
851    let mut candidates = Vec::new();
852    collect_insert_candidates(tree, max_children, 1, &mut candidates);
853    candidates.sort_by(|a, b| {
854        let a_score = (a.depth as f64).powi((a.index + max_children) as i32);
855        let b_score = (b.depth as f64).powi((b.index + max_children) as i32);
856        a_score.partial_cmp(&b_score).unwrap_or(std::cmp::Ordering::Equal)
857    });
858    candidates
859        .into_iter()
860        .next()
861        .map(|c| (c.node, c.index))
862        .unwrap_or((tree, tree.children.len()))
863}
864
865fn collect_insert_candidates<'a>(
866    node: &'a LayoutNode,
867    max_children: usize,
868    depth: usize,
869    out: &mut Vec<InsertCandidate<'a>>,
870) {
871    // Minimized is a locked state: never a blind-insert target. Without
872    // this, `ensure_group_node` promoting a minimized leaf into a branch
873    // could leave the tree in a state where an agent/API-driven insert
874    // silently un-collapses a fully-minimized subtree (every child
875    // minimized → one child isn't anymore, with no user action on that
876    // branch at all). Mirrors `findNextInsertLocationHelper`'s guard in
877    // `frontend/layout/lib/layoutNode.ts` (2026-07-18). For a branch this
878    // also skips recursing into its children — they're all effectively
879    // minimized too by definition, so recursion would find nothing anyway.
880    if is_effectively_minimized(node) {
881        return;
882    }
883    // Leaf node (has data but no children) — TS returns index 1.
884    if node.data.is_some() && node.children.is_empty() {
885        out.push(InsertCandidate { node, index: 1, depth });
886        return;
887    }
888    if node.children.len() < max_children {
889        out.push(InsertCandidate {
890            node,
891            index: node.children.len(),
892            depth,
893        });
894    }
895    // TS iterates children in REVERSE order.
896    for child in node.children.iter().rev() {
897        collect_insert_candidates(child, max_children, depth + 1, out);
898    }
899}
900
901// ── Core operations ────────────────────────────────────────────────────────
902
903/// Insert `node` using the TypeScript scoring heuristic (up to
904/// DEFAULT_MAX_CHILDREN per node before descending). If the target location
905/// is a leaf, it is promoted to a group first via `ensure_group_node`.
906/// Mirrors `insertNode` / `addChildAt` in `frontend/layout/lib/layoutTree.ts`.
907pub fn insert_node(root: &mut LayoutNode, node: LayoutNode) {
908    let loc = find_next_insert_location(root, DEFAULT_MAX_CHILDREN);
909    // `collect_insert_candidates` already excludes locked candidates; this
910    // only fires in the degenerate fallback case where every node in the
911    // tree is locked (the `.unwrap_or((tree, ...))` root fallback).
912    if is_effectively_minimized(loc.0) {
913        tracing::warn!(
914            node_id = %loc.0.id,
915            "insert_node: no unlocked insert location available, dropping"
916        );
917        return;
918    }
919    let loc_id = loc.0.id.clone();
920    if let Some(target) = find_node_by_id_mut(root, &loc_id) {
921        ensure_group_node(target);
922        target.children.push(node);
923    }
924}
925
926/// Insert `node` at the location identified by `index_arr` — an ordered
927/// sequence of child indices (e.g. `[0, 2]` = root.children[0].children
928/// at index 3). The node is inserted AFTER the position identified by the
929/// last index.
930///
931/// Mirrors `findInsertLocationFromIndexArr` in
932/// `frontend/layout/lib/layoutNode.ts`:
933/// - Each segment is **clamped** to `[0, children.len() - 1]`. Out-of-range
934///   indices do NOT error — they resolve to the last child slot.
935/// - Descent **stops at a leaf**. Any remaining segments after we hit a node
936///   with no children are ignored, and the leaf becomes the insert target
937///   (where `ensure_group_node` then promotes it). This matches the TS
938///   oracle's `if (indexArr.length == 0 || !node.children) return ...`.
939///
940/// This tolerance is required for "identical state transitions" with the
941/// frontend reducer under concurrent edits — the frontend may issue a
942/// stale or over-deep `indexArr` against a tree that has since shrunk.
943pub fn insert_node_at_index(
944    root: &mut LayoutNode,
945    node: LayoutNode,
946    index_arr: &[usize],
947) -> Result<(), LayoutError> {
948    if index_arr.is_empty() {
949        return Err(LayoutError::InvalidIndexPath);
950    }
951    let mut current = root as *mut LayoutNode;
952    let mut clamped_idx: usize = 0;
953    let last_seg = index_arr.len() - 1;
954
955    for (seg_pos, &idx) in index_arr.iter().enumerate() {
956        let cur = unsafe { &mut *current };
957        if cur.children.is_empty() {
958            // Leaf reached early — TS treats `node.children?.length ?? 1`
959            // as 1 here, so any non-negative idx clamps to 0. Stop descent.
960            clamped_idx = 0;
961            break;
962        }
963        clamped_idx = idx.min(cur.children.len() - 1);
964        if seg_pos < last_seg {
965            current = &mut cur.children[clamped_idx] as *mut LayoutNode;
966        }
967    }
968
969    let parent = unsafe { &mut *current };
970    // Minimized is a locked state: an `index_arr` that resolves into a
971    // dissolved column (locked container) or onto a minimized leaf (which
972    // `ensure_group_node` would otherwise promote into a group) is rejected.
973    if is_effectively_minimized(parent) {
974        return Err(LayoutError::NodeLocked { id: parent.id.clone() });
975    }
976    ensure_group_node(parent);
977    let insert_at = (clamped_idx + 1).min(parent.children.len());
978    parent.children.insert(insert_at, node);
979    Ok(())
980}
981
982/// Remove the node with the given id from the tree.
983/// Returns `Ok(())` on success, or `Err(NodeNotFound)` if `node_id` is not
984/// in the tree. Callers are responsible for clearing `focused_node_id` if
985/// needed (spec §7.1). Collapses single-child parents.
986pub fn delete_node(root: &mut LayoutNode, node_id: &str) -> Result<(), LayoutError> {
987    if root.id == node_id {
988        // Root deletion is handled by the caller setting root = None.
989        return Ok(());
990    }
991    match delete_recursive(root, node_id) {
992        true => Ok(()),
993        false => Err(LayoutError::NodeNotFound { id: node_id.to_string() }),
994    }
995}
996
997/// Recursive delete. Returns `true` when the node was found and removed,
998/// `false` when not found.
999fn delete_recursive(node: &mut LayoutNode, target_id: &str) -> bool {
1000    let idx = node.children.iter().position(|c| c.id == target_id);
1001    if let Some(i) = idx {
1002        // Found target as a direct child — remove it.
1003        node.children.remove(i);
1004        // Collapse: if only one child remains, promote it.
1005        // Preserve `extra` (unknown-field catch-all from #688/#689).
1006        if node.children.len() == 1 {
1007            let sole = node.children.remove(0);
1008            node.id = sole.id;
1009            node.size = sole.size;
1010            node.data = sole.data;
1011            node.flex_direction = sole.flex_direction;
1012            node.children = sole.children;
1013            node.extra = sole.extra;
1014        }
1015        return true;
1016    }
1017    // Not a direct child — recurse.
1018    for child in &mut node.children {
1019        if delete_recursive(child, target_id) {
1020            return true;
1021        }
1022    }
1023    false
1024}
1025
1026/// Move the node identified by `node_id` to be the child at `index` under
1027/// the node identified by `new_parent_id`. Resets the moved node's size to
1028/// DEFAULT_NODE_SIZE unless it stays under the same parent.
1029pub fn move_node(
1030    root: &mut LayoutNode,
1031    node_id: &str,
1032    new_parent_id: &str,
1033    index: usize,
1034) -> Result<(), LayoutError> {
1035    if root.id == node_id || root.id == new_parent_id {
1036        if new_parent_id == root.id && root.id != node_id {
1037            // Moving a child to root-as-parent is valid; proceed.
1038        } else if root.id == node_id {
1039            return Err(LayoutError::RootCannotBeTarget);
1040        }
1041    }
1042
1043    // Validate BOTH node existence AND destination BEFORE any mutation.
1044    let node_to_move = find_node_by_id(root, node_id)
1045        .ok_or_else(|| LayoutError::NodeNotFound { id: node_id.to_string() })?
1046        .clone();
1047    // Minimized is a locked state: a locked node can't be moved (restore it
1048    // first), and nothing may be inserted into a dissolved column.
1049    if is_effectively_minimized(&node_to_move) {
1050        return Err(LayoutError::NodeLocked { id: node_id.to_string() });
1051    }
1052    // Confirm destination exists while tree is still intact.
1053    match find_node_by_id(root, new_parent_id) {
1054        None => return Err(LayoutError::NodeNotFound { id: new_parent_id.to_string() }),
1055        Some(p) if is_effectively_minimized(p) => {
1056            return Err(LayoutError::NodeLocked { id: new_parent_id.to_string() });
1057        }
1058        Some(_) => {}
1059    }
1060    // Confirm destination is NOT a descendant of the node being moved.
1061    if find_node_by_id(&node_to_move, new_parent_id).is_some() {
1062        return Err(LayoutError::NodeNotFound {
1063            id: format!("{} (is a descendant of the moved node {})", new_parent_id, node_id),
1064        });
1065    }
1066
1067    let old_parent_id = find_parent_id(root, node_id);
1068    let same_parent = old_parent_id.as_deref() == Some(new_parent_id);
1069
1070    // Capture the node's current index inside the new parent BEFORE the
1071    // detach so we can compensate for the source-removal shift below.
1072    // Only meaningful when same_parent (otherwise cur_idx is None).
1073    let cur_idx_in_new_parent = if same_parent {
1074        find_node_by_id(root, new_parent_id)
1075            .and_then(|p| p.children.iter().position(|c| c.id == node_id))
1076    } else {
1077        None
1078    };
1079
1080    // Now it is safe to detach (both endpoints exist).
1081    remove_node_from_parent(root, node_id);
1082
1083    let mut node_with_size = node_to_move;
1084    if !same_parent {
1085        node_with_size.size = DEFAULT_NODE_SIZE;
1086    }
1087
1088    // Mirror the TypeScript oracle (`moveNode` in
1089    // frontend/layout/lib/layoutTree.ts): TS does insert-then-remove with
1090    // a `startingIndex` shift, which makes same-parent moves where the
1091    // requested index is past the current position resolve to one slot
1092    // earlier in the final array (because the to-be-removed source eats
1093    // a slot from `index`). We do detach-then-insert here, so we apply
1094    // that compensation by decrementing `index` when target > cur_idx.
1095    let effective_index = match cur_idx_in_new_parent {
1096        Some(ci) if index > ci => index - 1,
1097        _ => index,
1098    };
1099
1100    // Insert at new location (destination guaranteed to still exist).
1101    let new_parent = find_node_by_id_mut(root, new_parent_id).unwrap();
1102    ensure_group_node(new_parent);
1103    let insert_at = effective_index.min(new_parent.children.len());
1104    new_parent.children.insert(insert_at, node_with_size);
1105    Ok(())
1106}
1107
1108fn find_parent_id(root: &LayoutNode, child_id: &str) -> Option<String> {
1109    if root.children.iter().any(|c| c.id == child_id) {
1110        return Some(root.id.clone());
1111    }
1112    for child in &root.children {
1113        if let Some(pid) = find_parent_id(child, child_id) {
1114            return Some(pid);
1115        }
1116    }
1117    None
1118}
1119
1120fn remove_node_from_parent(root: &mut LayoutNode, node_id: &str) {
1121    if let Some(idx) = root.children.iter().position(|c| c.id == node_id) {
1122        root.children.remove(idx);
1123        return;
1124    }
1125    for child in &mut root.children {
1126        remove_node_from_parent(child, node_id);
1127    }
1128}
1129
1130/// Swap two nodes (by id). Their sizes travel with them (swap positions
1131/// but preserve the size each node had). Neither can be the root.
1132pub fn swap_nodes(
1133    root: &mut LayoutNode,
1134    node1_id: &str,
1135    node2_id: &str,
1136) -> Result<(), LayoutError> {
1137    if node1_id == node2_id {
1138        return Err(LayoutError::SelfSwap);
1139    }
1140    if root.id == node1_id || root.id == node2_id {
1141        return Err(LayoutError::RootCannotBeTarget);
1142    }
1143
1144    // Collect both nodes + parent info.
1145    let n1 = find_node_by_id(root, node1_id)
1146        .ok_or_else(|| LayoutError::NodeNotFound { id: node1_id.to_string() })?
1147        .clone();
1148    let n2 = find_node_by_id(root, node2_id)
1149        .ok_or_else(|| LayoutError::NodeNotFound { id: node2_id.to_string() })?
1150        .clone();
1151    // Minimized is a locked state: locked nodes don't swap.
1152    if is_effectively_minimized(&n1) {
1153        return Err(LayoutError::NodeLocked { id: node1_id.to_string() });
1154    }
1155    if is_effectively_minimized(&n2) {
1156        return Err(LayoutError::NodeLocked { id: node2_id.to_string() });
1157    }
1158    let p1_id = find_parent_id(root, node1_id)
1159        .ok_or_else(|| LayoutError::NodeNotFound { id: format!("parent of {}", node1_id) })?;
1160    let p2_id = find_parent_id(root, node2_id)
1161        .ok_or_else(|| LayoutError::NodeNotFound { id: format!("parent of {}", node2_id) })?;
1162
1163    // Detect ancestor/descendant relationship. If n1 is an ancestor of n2
1164    // (or vice versa), the first placement replaces a subtree that contains
1165    // the second node's parent, making the second lookup fail.
1166    if find_node_by_id(&n1, node2_id).is_some() || find_node_by_id(&n2, node1_id).is_some() {
1167        return Err(LayoutError::RootCannotBeTarget);
1168    }
1169
1170    // Find indices.
1171    let idx1 = {
1172        let p = find_node_by_id(root, &p1_id).unwrap();
1173        p.children.iter().position(|c| c.id == node1_id).unwrap()
1174    };
1175    let idx2 = {
1176        let p = find_node_by_id(root, &p2_id).unwrap();
1177        p.children.iter().position(|c| c.id == node2_id).unwrap()
1178    };
1179
1180    // Build swapped nodes: preserve sizes AT the slot, swap the nodes.
1181    let mut n1_swapped = n2.clone();
1182    n1_swapped.size = n1.size; // slot 1 keeps size of what was there (n1's size)
1183    let mut n2_swapped = n1.clone();
1184    n2_swapped.size = n2.size; // slot 2 keeps size of what was there (n2's size)
1185
1186    // Place them. p2_id is guaranteed to still exist (ancestor check above).
1187    let p1 = find_node_by_id_mut(root, &p1_id).unwrap();
1188    p1.children[idx1] = n1_swapped;
1189
1190    let p2 = find_node_by_id_mut(root, &p2_id).unwrap();
1191    p2.children[idx2] = n2_swapped;
1192
1193    Ok(())
1194}
1195
1196/// Apply resize operations. Validates all sizes first (0.0–100.0 range);
1197/// if any is invalid, returns an error WITHOUT applying any ops (atomically
1198/// rejected — matches the frontend's early-return semantic from PR #686).
1199pub fn resize_nodes(root: &mut LayoutNode, ops: &[ResizeOp]) -> Result<(), LayoutError> {
1200    // Validation pass 1: sizes.
1201    for op in ops {
1202        if !(0.0..=100.0).contains(&op.size) {
1203            return Err(LayoutError::InvalidSize {
1204                node_id: op.node_id.clone(),
1205                size: op.size,
1206            });
1207        }
1208    }
1209    // Validation pass 2: all target nodes exist and none is minimize-locked.
1210    // Ops come in before/after pairs whose unit sum is conserved — applying
1211    // only the unlocked half would leak flex units, so it's all-or-nothing
1212    // (same atomic-reject semantics as the passes above, and the same guard
1213    // as the frontend's `resizeNode`).
1214    for op in ops {
1215        match find_node_by_id(root, &op.node_id) {
1216            None => return Err(LayoutError::NodeNotFound { id: op.node_id.clone() }),
1217            Some(node) if is_effectively_minimized(node) => {
1218                return Err(LayoutError::NodeLocked { id: op.node_id.clone() });
1219            }
1220            Some(_) => {}
1221        }
1222    }
1223    // Apply pass (all valid).
1224    for op in ops {
1225        let node = find_node_by_id_mut(root, &op.node_id).unwrap();
1226        node.size = op.size;
1227    }
1228    Ok(())
1229}
1230
1231/// Replace the node at `target_id` with `new_node`, preserving the target's
1232/// `size`. If target is root, replaces root-in-place (caller's `Option<LayoutNode>`
1233/// doesn't change reference — the fields are updated on the root node).
1234pub fn replace_node(
1235    root: &mut LayoutNode,
1236    target_id: &str,
1237    new_node: LayoutNode,
1238) -> Result<(), LayoutError> {
1239    if root.id == target_id {
1240        let preserved_size = root.size;
1241        *root = new_node;
1242        root.size = preserved_size;
1243        return Ok(());
1244    }
1245    let parent_id = find_parent_id(root, target_id)
1246        .ok_or_else(|| LayoutError::NodeNotFound { id: target_id.to_string() })?;
1247    let parent = find_node_by_id_mut(root, &parent_id).unwrap();
1248    let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
1249    let preserved_size = parent.children[idx].size;
1250    parent.children[idx] = new_node;
1251    parent.children[idx].size = preserved_size;
1252    Ok(())
1253}
1254
1255/// Horizontal split: insert `new_node` before/after `target_id`.
1256///
1257/// - If the target's parent is a Row, splice directly.
1258/// - Otherwise, wrap target + new_node in a fresh Row group.
1259pub fn split_horizontal(
1260    root: &mut LayoutNode,
1261    target_id: &str,
1262    new_node: LayoutNode,
1263    position: SplitPosition,
1264) -> Result<(), LayoutError> {
1265    split_impl(root, target_id, new_node, position, FlexDirection::Row)
1266}
1267
1268/// Vertical split: insert `new_node` before/after `target_id`.
1269///
1270/// - If the target's parent is a Column, splice directly.
1271/// - Otherwise, wrap in a fresh Column group.
1272pub fn split_vertical(
1273    root: &mut LayoutNode,
1274    target_id: &str,
1275    new_node: LayoutNode,
1276    position: SplitPosition,
1277) -> Result<(), LayoutError> {
1278    split_impl(root, target_id, new_node, position, FlexDirection::Column)
1279}
1280
1281fn split_impl(
1282    root: &mut LayoutNode,
1283    target_id: &str,
1284    new_node: LayoutNode,
1285    position: SplitPosition,
1286    direction: FlexDirection,
1287) -> Result<(), LayoutError> {
1288    // Find target; it must exist and must not be minimize-locked (splitting
1289    // a locked node would spawn a full pane inside a header strip).
1290    let target = find_node_by_id(root, target_id)
1291        .ok_or_else(|| LayoutError::NodeNotFound { id: target_id.to_string() })?;
1292    if is_effectively_minimized(target) {
1293        return Err(LayoutError::NodeLocked { id: target_id.to_string() });
1294    }
1295
1296    let parent_id = find_parent_id(root, target_id);
1297
1298    if let Some(ref pid) = parent_id {
1299        let parent = find_node_by_id_mut(root, pid).unwrap();
1300        if parent.flex_direction == direction {
1301            // Parent already has the matching direction — splice directly.
1302            let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
1303            let insert_at = match position {
1304                SplitPosition::Before => idx,
1305                SplitPosition::After => idx + 1,
1306            };
1307            parent.children.insert(insert_at, new_node);
1308            return Ok(());
1309        }
1310        // Parent has wrong direction — wrap target in a new group.
1311        let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
1312        let target = parent.children.remove(idx);
1313        let target_size = target.size;
1314        let (children_ordered, new_flex) = match position {
1315            SplitPosition::Before => (vec![new_node, target], direction),
1316            SplitPosition::After => (vec![target, new_node], direction),
1317        };
1318        let group = LayoutNode {
1319            id: Uuid::new_v4().to_string(),
1320            flex_direction: new_flex,
1321            size: target_size,
1322            children: children_ordered,
1323            ..Default::default()
1324        };
1325        parent.children.insert(idx, group);
1326        return Ok(());
1327    }
1328
1329    // Target IS root (no parent) — wrap root in a new group.
1330    // We can't replace `root` itself, so we build the group children in-place.
1331    // Also take `root.extra` (unknown-field catch-all from #688/#689).
1332    // `..Default::default()` would silently drop it — reagent P1 PR #691.
1333    let root_clone = LayoutNode {
1334        id: root.id.clone(),
1335        flex_direction: root.flex_direction,
1336        size: root.size,
1337        children: std::mem::take(&mut root.children),
1338        data: root.data.take(),
1339        extra: std::mem::take(&mut root.extra),
1340    };
1341    let root_size = root_clone.size;
1342    let new_group_id = Uuid::new_v4().to_string();
1343    let (children_ordered, new_flex) = match position {
1344        SplitPosition::Before => (vec![new_node, root_clone], direction),
1345        SplitPosition::After => (vec![root_clone, new_node], direction),
1346    };
1347    root.id = new_group_id;
1348    root.flex_direction = new_flex;
1349    root.size = root_size;
1350    root.children = children_ordered;
1351    root.data = None;
1352    Ok(())
1353}
1354
1355// ── Tree normalization (balanceNode) ────────────────────────────────────────
1356
1357/// Validate the leaf-XOR-branch invariant. Mirrors `validateNode` in
1358/// `frontend/layout/lib/layoutNode.ts`: a node must have **either** `data`
1359/// **or** `children`, never both and never neither. (In TS the "empty
1360/// children array" case is a separate check; here an empty `children` vec
1361/// with no `data` is the same "neither" case and is rejected by the single
1362/// XOR below.)
1363fn validate_node(node: &LayoutNode) -> bool {
1364    let has_children = !node.children.is_empty();
1365    let has_data = node.data.is_some();
1366    has_children != has_data
1367}
1368
1369/// Port of `balanceNode` in `frontend/layout/lib/layoutNode.ts`. Recursively
1370/// normalizes the tree: validates each node, alternates child flex direction,
1371/// hoists redundant single-child-branch chains, drops empty branches, and
1372/// collapses a branch with a single leaf child into that leaf.
1373///
1374/// Returns `Err(InvalidNode)` where the TS oracle throws "Invalid node", so
1375/// the reducer can surface an `Event::Error` rather than panic.
1376///
1377/// Faithfulness notes (matched to the TS oracle, deliberately NOT "improved"):
1378/// - The direction flip mutates only the *iterated* child; hoisted
1379///   grandchildren are returned unflipped (a later pass alternates them) —
1380///   matches the TS `flatMap`.
1381/// - `_slipAnchor` (read from the untyped `extra` catch-all, where the
1382///   frontend stores it) suppresses the single-child-branch hoist.
1383/// - `columnDissolve` (same `extra` catch-all) suppresses the direction-flip
1384///   alternation, so a dissolved column's own children stay stacked.
1385/// - The single-leaf collapse copies the child's `data` + `id` only; the node
1386///   keeps its own `size` / `flex_direction` / `extra` — exactly as TS does.
1387pub fn balance_node(node: &mut LayoutNode) -> Result<(), LayoutError> {
1388    // BEFORE-walk: validate, then rebuild children (flip / hoist / drop).
1389    if !validate_node(node) {
1390        return Err(LayoutError::InvalidNode { id: node.id.clone() });
1391    }
1392    let parent_flex = node.flex_direction;
1393    let mut rebuilt: Vec<LayoutNode> = Vec::with_capacity(node.children.len());
1394    for mut child in std::mem::take(&mut node.children) {
1395        // A dissolved column (`columnDissolve` set, in `extra`) must keep the
1396        // flexDirection it had when its leaf children were minimized — that's
1397        // what stacks them vertically inside the header strip. It's nested
1398        // under a sibling column of the same direction by design (see
1399        // `_dissolveColumn` in layoutMinimize.ts), so the alternation rule
1400        // below would otherwise flip it and lay the headers out sideways.
1401        let column_dissolve = child.extra.contains_key("columnDissolve");
1402        if child.flex_direction == parent_flex && !column_dissolve {
1403            child.flex_direction = reverse_flex_direction(parent_flex);
1404        }
1405        let slip_anchor = child
1406            .extra
1407            .get("_slipAnchor")
1408            .and_then(|v| v.as_bool())
1409            .unwrap_or(false);
1410        // Hoist: child has exactly one child and that grandchild is itself a
1411        // branch (TS `child.children[0].children` truthy) — replace `child`
1412        // with the grandchild's children. Suppressed for slip anchors.
1413        if child.children.len() == 1 && !child.children[0].children.is_empty() && !slip_anchor {
1414            let grandchild = child.children.remove(0);
1415            rebuilt.extend(grandchild.children);
1416            continue;
1417        }
1418        // Drop an empty branch (no data, no children). A leaf (data present,
1419        // children empty) is kept — TS `children?.length === 0` is false for a
1420        // leaf whose `children` is `undefined`.
1421        if child.children.is_empty() && child.data.is_none() {
1422            continue;
1423        }
1424        rebuilt.push(child);
1425    }
1426    node.children = rebuilt;
1427
1428    // Recurse into the rebuilt children (walkNodes' `forEach`, which iterates
1429    // the post-rebuild children because the before-callback ran first).
1430    for child in &mut node.children {
1431        balance_node(child)?;
1432    }
1433
1434    // AFTER-walk: collapse a single leaf child into this node.
1435    if node.children.len() == 1 && node.children[0].children.is_empty() {
1436        let only = node.children.remove(0);
1437        node.data = only.data;
1438        node.id = only.id;
1439        node.children = Vec::new();
1440    }
1441    Ok(())
1442}
1443
1444// ── Tests ──────────────────────────────────────────────────────────────────
1445#[cfg(test)]
1446mod tests;