agentmux_srv\backend\wcore/
block.rs1#![allow(dead_code)]
2use uuid::Uuid;
8
9use crate::backend::storage::wstore::WaveStore;
10use crate::backend::storage::StoreError;
11use crate::backend::obj::*;
12
13pub fn create_block(
15 store: &WaveStore,
16 tab_id: &str,
17 meta: MetaMapType,
18) -> Result<Block, StoreError> {
19 let mut tab = store.must_get::<Tab>(tab_id)?;
20
21 let mut block = Block {
22 oid: Uuid::new_v4().to_string(),
23 parentoref: format!("tab:{}", tab_id),
24 meta,
25 ..Default::default()
26 };
27 store.insert(&mut block)?;
28
29 tab.blockids.push(block.oid.clone());
30 store.update(&mut tab)?;
31
32 Ok(block)
33}
34
35pub fn delete_block(
37 store: &WaveStore,
38 tab_id: &str,
39 block_id: &str,
40) -> Result<(), StoreError> {
41 let mut tab = store.must_get::<Tab>(tab_id)?;
42 tab.blockids.retain(|id| id != block_id);
43 store.update(&mut tab)?;
44 store.delete::<Block>(block_id)?;
45
46 if !tab.layoutstate.is_empty() {
51 if let Ok(Some(mut layout)) = store.get::<LayoutState>(&tab.layoutstate) {
52 tracing::info!(
53 block_id = %block_id,
54 layout_id = %tab.layoutstate,
55 "pruning deleted block from layout tree"
56 );
57 prune_block_from_layout(&mut layout, block_id);
58 let _ = store.update(&mut layout);
59 }
60 }
61 Ok(())
62}
63
64fn prune_block_from_layout(layout: &mut LayoutState, block_id: &str) {
66 if let Some(ref mut leaves) = layout.leaforder {
68 leaves.retain(|entry| entry.blockid != block_id);
69 }
70
71 let root_is_orphan = layout.rootnode
78 .as_ref()
79 .and_then(|r| r.data.as_ref())
80 .map(|d| d.block_id == block_id)
81 .unwrap_or(false);
82 if root_is_orphan {
83 layout.rootnode = None;
84 } else if let Some(ref mut root) = layout.rootnode {
85 prune_node(root, block_id);
86 }
87}
88
89fn prune_node(node: &mut LayoutNode, block_id: &str) {
95 node.children.retain(|child| {
97 child
98 .data
99 .as_ref()
100 .map(|d| d.block_id != block_id)
101 .unwrap_or(true) });
103 for child in node.children.iter_mut() {
105 prune_node(child, block_id);
106 }
107 if node.children.len() == 1 {
110 let parent_id = node.id.clone();
111 let parent_size = node.size;
112 let sole_child = node.children.remove(0);
113 *node = sole_child;
114 node.id = parent_id;
116 node.size = parent_size;
117 }
118}
119
120pub fn heal_layout(
123 store: &WaveStore,
124 tab_id: &str,
125) -> Result<bool, StoreError> {
126 let tab = store.must_get::<Tab>(tab_id)?;
127 if tab.layoutstate.is_empty() {
128 return Ok(false);
129 }
130 let mut layout = match store.get::<LayoutState>(&tab.layoutstate)? {
131 Some(l) => l,
132 None => return Ok(false),
133 };
134
135 let valid_blocks: std::collections::HashSet<&str> =
136 tab.blockids.iter().map(|s| s.as_str()).collect();
137
138 let (changed, orphans) = heal_layout_body(&mut layout, &valid_blocks);
139 if !changed {
140 return Ok(false);
141 }
142 tracing::warn!(
143 tab_id = %tab_id,
144 orphan_count = orphans.len(),
145 orphans = ?orphans,
146 "healing layout: removing orphaned block nodes"
147 );
148 store.update(&mut layout)?;
149 Ok(true)
150}
151
152fn heal_layout_body(
161 layout: &mut LayoutState,
162 valid_blocks: &std::collections::HashSet<&str>,
163) -> (bool, Vec<String>) {
164 let mut orphans: Vec<String> = layout.leaforder
166 .as_ref()
167 .map(|leaves| {
168 leaves.iter()
169 .filter(|e| !valid_blocks.contains(e.blockid.as_str()))
170 .map(|e| e.blockid.clone())
171 .collect()
172 })
173 .unwrap_or_default();
174
175 if let Some(ref root) = layout.rootnode {
179 collect_leaf_block_ids(root, &mut |id| {
180 if !valid_blocks.contains(id) && !orphans.iter().any(|o| o == id) {
181 orphans.push(id.to_string());
182 }
183 });
184 }
185
186 if orphans.is_empty() {
187 return (false, orphans);
188 }
189
190 for orphan_id in &orphans {
191 prune_block_from_layout(layout, orphan_id);
192 }
193
194 if layout.rootnode.is_none() && !layout.focusednodeid.is_empty() {
196 layout.focusednodeid = String::new();
197 }
198
199 (true, orphans)
200}
201
202fn collect_leaf_block_ids(node: &LayoutNode, sink: &mut dyn FnMut(&str)) {
205 if !node.children.is_empty() {
206 for child in &node.children {
207 collect_leaf_block_ids(child, sink);
208 }
209 return;
210 }
211 if let Some(data) = &node.data {
212 sink(&data.block_id);
213 }
214}
215
216#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde_json::json;
222
223 fn empty_layout(oid: &str) -> LayoutState {
224 LayoutState {
225 oid: oid.to_string(),
226 version: 1,
227 rootnode: None,
228 leaforder: None,
229 focusednodeid: String::new(),
230 magnifiednodeid: String::new(),
231 pendingbackendactions: None,
232 meta: None,
233 }
234 }
235
236 fn leaf(id: &str, block_id: &str, size: f32) -> LayoutNode {
237 LayoutNode {
238 id: id.into(),
239 flex_direction: FlexDirection::Row,
240 size,
241 children: Vec::new(),
242 data: Some(LayoutNodeData {
243 block_id: block_id.into(),
244 ..Default::default()
245 }),
246 ..Default::default()
247 }
248 }
249
250 fn single_leaf_layout(block_id: &str, node_id: &str) -> LayoutState {
251 LayoutState {
252 oid: "layout-1".into(),
253 version: 1,
254 rootnode: Some(leaf(node_id, block_id, 10.0)),
255 leaforder: Some(vec![LeafOrderEntry {
256 blockid: block_id.into(),
257 nodeid: node_id.into(),
258 }]),
259 focusednodeid: node_id.into(),
260 magnifiednodeid: String::new(),
261 pendingbackendactions: None,
262 meta: None,
263 }
264 }
265
266 fn two_leaf_split_layout(left_block: &str, right_block: &str) -> LayoutState {
267 LayoutState {
268 oid: "layout-1".into(),
269 version: 1,
270 rootnode: Some(LayoutNode {
271 id: "split-1".into(),
272 flex_direction: FlexDirection::Row,
273 size: 10.0,
274 children: vec![
275 leaf("leaf-left", left_block, 5.0),
276 leaf("leaf-right", right_block, 5.0),
277 ],
278 data: None,
279 ..Default::default()
280 }),
281 leaforder: Some(vec![
282 LeafOrderEntry { blockid: left_block.into(), nodeid: "leaf-left".into() },
283 LeafOrderEntry { blockid: right_block.into(), nodeid: "leaf-right".into() },
284 ]),
285 focusednodeid: "leaf-left".into(),
286 magnifiednodeid: String::new(),
287 pendingbackendactions: None,
288 meta: None,
289 }
290 }
291
292 fn set<'a>(ids: &[&'a str]) -> std::collections::HashSet<&'a str> {
293 ids.iter().copied().collect()
294 }
295
296 #[test]
297 fn prune_removes_rootnode_leaf_when_it_is_orphan() {
298 let mut layout = single_leaf_layout("orphan-block", "node-1");
302 prune_block_from_layout(&mut layout, "orphan-block");
303
304 assert!(layout.rootnode.is_none(),
305 "rootnode must be cleared when it was the orphan leaf");
306 assert_eq!(
307 layout.leaforder.as_deref().map(|l| l.len()),
308 Some(0),
309 "leaforder entry must be removed too",
310 );
311 }
312
313 #[test]
314 fn prune_removes_child_leaf_and_collapses() {
315 let mut layout = two_leaf_split_layout("keep", "drop");
317 prune_block_from_layout(&mut layout, "drop");
318
319 assert!(layout.rootnode.is_some(), "rootnode should survive");
320 let root = layout.rootnode.as_ref().unwrap();
323 let kept_block = root.data.as_ref().map(|d| d.block_id.as_str());
324 assert_eq!(kept_block, Some("keep"),
325 "after pruning 'drop' and collapsing, rootnode should be the 'keep' leaf");
326 }
327
328 #[test]
329 fn prune_noop_when_block_absent() {
330 let mut layout = single_leaf_layout("other-block", "node-1");
331 let before = serde_json::to_string(&layout.rootnode).unwrap();
332 prune_block_from_layout(&mut layout, "not-present");
333 let after = serde_json::to_string(&layout.rootnode).unwrap();
334 assert_eq!(before, after);
335 }
336
337 #[test]
338 fn heal_body_clears_focused_nodeid_when_rootnode_drops() {
339 let mut layout = single_leaf_layout("orphan-block", "node-1");
340 assert_eq!(layout.focusednodeid, "node-1");
341 let (changed, _orphans) = heal_layout_body(&mut layout, &set(&[]));
342 assert!(changed);
343 assert!(layout.rootnode.is_none());
344 assert_eq!(layout.focusednodeid, "",
345 "focusednodeid must be cleared when rootnode is empty");
346 }
347
348 #[test]
349 fn heal_body_catches_rootnode_orphan_missing_from_leaforder() {
350 let mut layout = single_leaf_layout("orphan-block", "node-1");
353 layout.leaforder = Some(vec![]); let (changed, orphans) = heal_layout_body(&mut layout, &set(&[]));
356 assert!(changed, "healer must notice rootnode-only orphan");
357 assert!(orphans.contains(&"orphan-block".to_string()));
358 assert!(layout.rootnode.is_none());
359 }
360
361 #[test]
362 fn heal_body_idempotent_on_clean_layout() {
363 let mut layout = single_leaf_layout("live-block", "node-1");
364 let (changed, _) = heal_layout_body(&mut layout, &set(&["live-block"]));
365 assert!(!changed, "no orphans → no change");
366 let (changed_again, _) = heal_layout_body(&mut layout, &set(&["live-block"]));
368 assert!(!changed_again);
369 }
370
371 #[test]
372 fn heal_body_handles_empty_layout() {
373 let mut layout = empty_layout("empty-layout");
374 let (changed, orphans) = heal_layout_body(&mut layout, &set(&[]));
375 assert!(!changed);
376 assert!(orphans.is_empty());
377 }
378}
379
380pub fn resolve_block_id_from_prefix(
382 store: &WaveStore,
383 tab_id: &str,
384 prefix: &str,
385) -> Result<String, StoreError> {
386 if prefix.len() != 8 {
387 return Err(StoreError::Other(
388 "block_id prefix must be 8 characters".to_string(),
389 ));
390 }
391 let tab = store.must_get::<Tab>(tab_id)?;
392 for block_id in &tab.blockids {
393 if block_id.starts_with(prefix) {
394 return Ok(block_id.clone());
395 }
396 }
397 Err(StoreError::NotFound)
398}