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;