agentmux_srv\backend\wcore/
mod.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Wave Core: application coordinator for storage + pub/sub.
6//! Port of Go's pkg/wcore/wcore.go + window.go + workspace.go + block.go.
7//!
8//! Orchestrates WaveStore mutations with WPS event publishing.
9
10mod block;
11mod dnd;
12mod event;
13mod tab;
14mod window;
15mod workspace;
16
17// Re-export all public APIs so callers can continue using `wcore::function_name`.
18pub use block::*;
19pub use dnd::*;
20#[allow(unused_imports)]
21pub use event::*;
22pub use tab::*;
23pub use window::*;
24pub use workspace::*;
25
26use uuid::Uuid;
27
28use super::storage::wstore::WaveStore;
29use super::storage::StoreError;
30use super::obj::*;
31
32// ---- Layout action types (match Go) ----
33
34pub const LAYOUT_ACTION_INSERT: &str = "insert";
35pub const LAYOUT_ACTION_INSERT_AT_INDEX: &str = "insertatindex";
36pub const LAYOUT_ACTION_REMOVE: &str = "remove";
37pub const LAYOUT_ACTION_CLEAR_TREE: &str = "cleartree";
38pub const LAYOUT_ACTION_REPLACE: &str = "replace";
39pub const LAYOUT_ACTION_SPLIT_HORIZONTAL: &str = "splithorizontal";
40pub const LAYOUT_ACTION_SPLIT_VERTICAL: &str = "splitvertical";
41
42// ---- Core operations ----
43
44/// Ensure initial data is present in the store.
45/// Creates a default Client, Window, Workspace, Tab if the store is empty.
46/// Returns `true` if this is a first launch (client was just created).
47pub fn ensure_initial_data(store: &WaveStore) -> Result<bool, StoreError> {
48    let clients = store.get_all::<Client>()?;
49
50    if !clients.is_empty() {
51        // Already initialized
52        let client = &clients[0];
53        if client.tempoid.is_empty() {
54            let mut client = client.clone();
55            client.tempoid = Uuid::new_v4().to_string();
56            store.update(&mut client)?;
57        }
58        // Check and fix windows
59        for window_id in &client.windowids {
60            window::check_and_fix_window(store, window_id)?;
61        }
62        return Ok(false);
63    }
64
65    // First launch: create client + window + workspace + tab
66    let first_launch = true;
67
68    // Go inserts client first (version 1), then updates TempOID (version 2).
69    // We mirror that to keep the version counter in sync.
70    let mut client = Client {
71        oid: Uuid::new_v4().to_string(),
72        windowids: vec![],
73        tempoid: String::new(),
74        meta: MetaMapType::new(),
75        ..Default::default()
76    };
77
78    store.insert(&mut client)?;
79
80    // Separate update for TempOID (matches Go's version 2 update)
81    client.tempoid = Uuid::new_v4().to_string();
82    store.update(&mut client)?;
83
84    // Create starter workspace
85    let ws = create_workspace(store, "Starter workspace")?;
86
87    // Create window pointing to workspace
88    let win = create_window(store, &ws.oid)?;
89
90    // Update client with window ID
91    client.windowids.push(win.oid.clone());
92    store.update(&mut client)?;
93
94    // Create initial tab in workspace (pinned, matching Go's isInitialLaunch=true)
95    let tab = create_tab_with_opts(store, &ws.oid, "", true)?;
96
97    // Seed the default 3-block layout:
98    //
99    //   ┌────────────────┬──────────────┐
100    //   │                │   sysinfo    │  size 2 of 10 ≈ 20%
101    //   │     agent      ├──────────────┤
102    //   │    (tall)      │              │
103    //   │                │    swarm     │  size 8 of 10 ≈ 80%
104    //   │                │              │
105    //   └────────────────┴──────────────┘
106    //        50% width         50% width
107    //
108    // We build `rootnode` directly instead of queuing `pendingbackendactions`
109    // because the frontend reducer has races when reducing multiple insert+
110    // split actions in a single drain cycle (the stress-harness hit this
111    // earlier — `layoutPersistence.ts` + `layoutNodeModels.ts::getNodeByBlockId`).
112    // A pre-built tree skips the reducer entirely; the frontend just renders.
113    let mut agent_meta = MetaMapType::new();
114    agent_meta.insert("view".to_string(), serde_json::json!("agent"));
115    let agent_block = create_block(store, &tab.oid, agent_meta)?;
116
117    let mut sysinfo_meta = MetaMapType::new();
118    sysinfo_meta.insert("view".to_string(), serde_json::json!("sysinfo"));
119    let sysinfo_block = create_block(store, &tab.oid, sysinfo_meta)?;
120
121    let mut swarm_meta = MetaMapType::new();
122    swarm_meta.insert("view".to_string(), serde_json::json!("swarm"));
123    let swarm_block = create_block(store, &tab.oid, swarm_meta)?;
124
125    // Node IDs for each tree position. Leaves get their own node IDs distinct
126    // from the block IDs they wrap.
127    let agent_node_id = Uuid::new_v4().to_string();
128    let sysinfo_node_id = Uuid::new_v4().to_string();
129    let swarm_node_id = Uuid::new_v4().to_string();
130    let right_col_id = Uuid::new_v4().to_string();
131    let root_id = Uuid::new_v4().to_string();
132
133    // Phase E.4.B Phase 2 — typed LayoutNode (was inline JSON).
134    let rootnode = LayoutNode {
135        id: root_id,
136        flex_direction: FlexDirection::Row,
137        size: 10.0,
138        data: None,
139        children: vec![
140            LayoutNode {
141                id: agent_node_id.clone(),
142                flex_direction: FlexDirection::Column,
143                size: 5.0,
144                children: Vec::new(),
145                data: Some(LayoutNodeData {
146                    block_id: agent_block.oid.clone(),
147                    ..Default::default()
148                }),
149                ..Default::default()
150            },
151            LayoutNode {
152                id: right_col_id,
153                flex_direction: FlexDirection::Column,
154                size: 5.0,
155                children: vec![
156                    LayoutNode {
157                        id: sysinfo_node_id.clone(),
158                        flex_direction: FlexDirection::Row,
159                        size: 2.0,
160                        children: Vec::new(),
161                        data: Some(LayoutNodeData {
162                            block_id: sysinfo_block.oid.clone(),
163                            ..Default::default()
164                        }),
165                        ..Default::default()
166                    },
167                    LayoutNode {
168                        id: swarm_node_id.clone(),
169                        flex_direction: FlexDirection::Row,
170                        size: 8.0,
171                        children: Vec::new(),
172                        data: Some(LayoutNodeData {
173                            block_id: swarm_block.oid.clone(),
174                            ..Default::default()
175                        }),
176                        ..Default::default()
177                    },
178                ],
179                data: None,
180                ..Default::default()
181            },
182        ],
183        ..Default::default()
184    };
185
186    let mut layout = store.must_get::<LayoutState>(&tab.layoutstate)?;
187    layout.rootnode = Some(rootnode);
188    layout.focusednodeid = agent_node_id.clone();
189    layout.leaforder = Some(vec![
190        LeafOrderEntry { nodeid: agent_node_id, blockid: agent_block.oid.clone() },
191        LeafOrderEntry { nodeid: sysinfo_node_id, blockid: sysinfo_block.oid.clone() },
192        LeafOrderEntry { nodeid: swarm_node_id, blockid: swarm_block.oid.clone() },
193    ]);
194    layout.pendingbackendactions = None;
195    store.update(&mut layout)?;
196
197    Ok(first_launch)
198}
199
200/// Get the singleton client record.
201pub fn get_client(store: &WaveStore) -> Result<Client, StoreError> {
202    let clients = store.get_all::<Client>()?;
203    clients.into_iter().next().ok_or(StoreError::NotFound)
204}
205
206// ====================================================================
207// Tests
208// ====================================================================
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn make_store() -> WaveStore {
215        WaveStore::open_in_memory().unwrap()
216    }
217
218    #[test]
219    fn test_ensure_initial_data_first_launch() {
220        let store = make_store();
221        let first = ensure_initial_data(&store).unwrap();
222        assert!(first);
223
224        // Should have created client, window, workspace, tab
225        let client = get_client(&store).unwrap();
226        assert_eq!(client.windowids.len(), 1);
227        assert!(!client.tempoid.is_empty());
228
229        let windows = store.get_all::<Window>().unwrap();
230        assert_eq!(windows.len(), 1);
231        // Window should have pos:{0,0} and winsize:{0,0} (matching Go)
232        assert_eq!(windows[0].pos.x, 0);
233        assert_eq!(windows[0].pos.y, 0);
234        assert_eq!(windows[0].winsize.width, 0);
235        assert_eq!(windows[0].winsize.height, 0);
236
237        let workspaces = store.get_all::<Workspace>().unwrap();
238        assert_eq!(workspaces.len(), 1);
239        assert_eq!(workspaces[0].name, "Starter workspace");
240        // Starter tab should be pinned (matching Go's isInitialLaunch=true)
241        assert_eq!(workspaces[0].pinnedtabids.len(), 1);
242        assert_eq!(workspaces[0].tabids.len(), 0);
243
244        let tabs = store.get_all::<Tab>().unwrap();
245        assert_eq!(tabs.len(), 1);
246        // Tab should be named "tab1" (per SPEC_TAB_GAPS_AND_NAMING_2026_04_25 —
247        // auto-generated tabs use the `tabN` convention, not the older
248        // `Untitled1` name. The test was asserting against stale-spec naming).
249        assert_eq!(tabs[0].name, "tab1");
250    }
251
252    #[test]
253    fn test_ensure_initial_data_idempotent() {
254        let store = make_store();
255        let first = ensure_initial_data(&store).unwrap();
256        assert!(first);
257
258        let second = ensure_initial_data(&store).unwrap();
259        assert!(!second);
260
261        // Should still have exactly 1 client
262        assert_eq!(store.count::<Client>().unwrap(), 1);
263    }
264
265    #[test]
266    fn test_create_and_delete_workspace() {
267        let store = make_store();
268        let ws = create_workspace(&store, "Test WS").unwrap();
269        assert_eq!(ws.name, "Test WS");
270
271        // Create tabs in workspace
272        let t1 = create_tab(&store, &ws.oid).unwrap();
273        let t2 = create_tab(&store, &ws.oid).unwrap();
274
275        let ws = get_workspace(&store, &ws.oid).unwrap();
276        assert_eq!(ws.tabids.len(), 2);
277
278        // Delete workspace cascades to tabs
279        let t1_oid = t1.oid.clone();
280        let t2_oid = t2.oid.clone();
281        delete_workspace(&store, &ws.oid).unwrap();
282        assert!(store.get::<Workspace>(&ws.oid).unwrap().is_none());
283        assert!(store.get::<Tab>(&t1_oid).unwrap().is_none());
284        assert!(store.get::<Tab>(&t2_oid).unwrap().is_none());
285    }
286
287    #[test]
288    fn test_create_and_delete_tab() {
289        let store = make_store();
290        let ws = create_workspace(&store, "WS").unwrap();
291        let tab1 = create_tab(&store, &ws.oid).unwrap();
292        let tab2 = create_tab(&store, &ws.oid).unwrap();
293
294        let ws = get_workspace(&store, &ws.oid).unwrap();
295        assert_eq!(ws.tabids.len(), 2);
296        assert_eq!(ws.activetabid, tab1.oid);
297
298        // Delete active tab — active should switch to tab2
299        delete_tab(&store, &ws.oid, &tab1.oid).unwrap();
300        let ws = get_workspace(&store, &ws.oid).unwrap();
301        assert_eq!(ws.tabids.len(), 1);
302        assert_eq!(ws.activetabid, tab2.oid);
303    }
304
305    #[test]
306    fn test_set_active_tab() {
307        let store = make_store();
308        let ws = create_workspace(&store, "WS").unwrap();
309        let _tab1 = create_tab(&store, &ws.oid).unwrap();
310        let tab2 = create_tab(&store, &ws.oid).unwrap();
311
312        set_active_tab(&store, &ws.oid, &tab2.oid).unwrap();
313        let ws = get_workspace(&store, &ws.oid).unwrap();
314        assert_eq!(ws.activetabid, tab2.oid);
315
316        // Setting non-existent tab should fail
317        let result = set_active_tab(&store, &ws.oid, "nonexistent");
318        assert!(result.is_err());
319    }
320
321    #[test]
322    fn test_create_and_delete_block() {
323        let store = make_store();
324        let ws = create_workspace(&store, "WS").unwrap();
325        let tab = create_tab(&store, &ws.oid).unwrap();
326
327        let mut meta = MetaMapType::new();
328        meta.insert("view".to_string(), serde_json::json!("term"));
329        let block = create_block(&store, &tab.oid, meta).unwrap();
330
331        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
332        assert_eq!(tab.blockids.len(), 1);
333        assert_eq!(tab.blockids[0], block.oid);
334
335        let loaded = store.must_get::<Block>(&block.oid).unwrap();
336        assert_eq!(loaded.parentoref, format!("tab:{}", tab.oid));
337        assert_eq!(loaded.meta.get("view").unwrap(), "term");
338
339        delete_block(&store, &tab.oid, &block.oid).unwrap();
340        assert!(store.get::<Block>(&block.oid).unwrap().is_none());
341        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
342        assert!(tab.blockids.is_empty());
343    }
344
345    #[test]
346    fn test_create_and_close_window() {
347        let store = make_store();
348        ensure_initial_data(&store).unwrap();
349
350        let client = get_client(&store).unwrap();
351        let initial_count = client.windowids.len();
352
353        let ws = create_workspace(&store, "WS2").unwrap();
354        let window = create_window(&store, &ws.oid).unwrap();
355
356        // Add to client
357        let mut client = get_client(&store).unwrap();
358        client.windowids.push(window.oid.clone());
359        store.update(&mut client).unwrap();
360
361        close_window(&store, &window.oid).unwrap();
362        let client = get_client(&store).unwrap();
363        assert_eq!(client.windowids.len(), initial_count);
364        assert!(store.get::<Window>(&window.oid).unwrap().is_none());
365    }
366
367    #[test]
368    fn test_focus_window() {
369        let store = make_store();
370        ensure_initial_data(&store).unwrap();
371
372        let ws = create_workspace(&store, "WS2").unwrap();
373        let w2 = create_window(&store, &ws.oid).unwrap();
374        let mut client = get_client(&store).unwrap();
375        client.windowids.push(w2.oid.clone());
376        store.update(&mut client).unwrap();
377
378        // w2 should be last, focus should move it first
379        focus_window(&store, &w2.oid).unwrap();
380        let client = get_client(&store).unwrap();
381        assert_eq!(client.windowids[0], w2.oid);
382    }
383
384    #[test]
385    fn test_switch_workspace() {
386        let store = make_store();
387        ensure_initial_data(&store).unwrap();
388
389        let client = get_client(&store).unwrap();
390        let window_id = &client.windowids[0];
391        let window = store.must_get::<Window>(window_id).unwrap();
392        let old_ws = window.workspaceid.clone();
393
394        let new_ws = create_workspace(&store, "New WS").unwrap();
395        switch_workspace(&store, window_id, &new_ws.oid).unwrap();
396
397        let window = store.must_get::<Window>(window_id).unwrap();
398        assert_eq!(window.workspaceid, new_ws.oid);
399        assert_ne!(window.workspaceid, old_ws);
400    }
401
402    #[test]
403    fn test_resolve_block_id_prefix() {
404        let store = make_store();
405        let ws = create_workspace(&store, "WS").unwrap();
406        let tab = create_tab(&store, &ws.oid).unwrap();
407
408        let meta = MetaMapType::new();
409        let block = create_block(&store, &tab.oid, meta).unwrap();
410        let prefix = &block.oid[..8];
411
412        let resolved = resolve_block_id_from_prefix(&store, &tab.oid, prefix).unwrap();
413        assert_eq!(resolved, block.oid);
414
415        // Non-matching prefix
416        let result = resolve_block_id_from_prefix(&store, &tab.oid, "00000000");
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn test_list_workspaces() {
422        let store = make_store();
423        create_workspace(&store, "WS1").unwrap();
424        create_workspace(&store, "WS2").unwrap();
425        create_workspace(&store, "WS3").unwrap();
426
427        let all = list_workspaces(&store).unwrap();
428        assert_eq!(all.len(), 3);
429    }
430
431    #[test]
432    fn test_check_and_fix_window_missing_workspace() {
433        let store = make_store();
434        // Create a window pointing to a non-existent workspace
435        let mut window = Window {
436            oid: Uuid::new_v4().to_string(),
437            workspaceid: "nonexistent".to_string(),
438            pos: Point { x: 0, y: 0 },
439            winsize: WinSize {
440                width: 800,
441                height: 600,
442            },
443            meta: MetaMapType::new(),
444            ..Default::default()
445        };
446        store.insert(&mut window).unwrap();
447
448        window::check_and_fix_window(&store, &window.oid).unwrap();
449
450        // Should have created a new workspace and pointed window to it
451        let fixed = store.must_get::<Window>(&window.oid).unwrap();
452        assert_ne!(fixed.workspaceid, "nonexistent");
453        let ws = store.must_get::<Workspace>(&fixed.workspaceid).unwrap();
454        assert_eq!(ws.tabids.len(), 1); // should have created a tab too
455    }
456
457    #[test]
458    fn test_move_block_to_tab() {
459        let store = make_store();
460        let ws = create_workspace(&store, "WS").unwrap();
461        let tab1 = create_tab(&store, &ws.oid).unwrap();
462        let tab2 = create_tab(&store, &ws.oid).unwrap();
463
464        let meta = MetaMapType::new();
465        let block = create_block(&store, &tab1.oid, meta).unwrap();
466
467        // Verify block is in tab1
468        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
469        assert_eq!(t1.blockids.len(), 1);
470        assert_eq!(t1.blockids[0], block.oid);
471
472        // Move block from tab1 to tab2
473        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, false).unwrap();
474
475        // tab1 should be empty, tab2 should have the block
476        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
477        let t2 = store.must_get::<Tab>(&tab2.oid).unwrap();
478        assert!(t1.blockids.is_empty());
479        assert_eq!(t2.blockids.len(), 1);
480        assert_eq!(t2.blockids[0], block.oid);
481
482        // Block parentoref should point to tab2
483        let b = store.must_get::<Block>(&block.oid).unwrap();
484        assert_eq!(b.parentoref, format!("tab:{}", tab2.oid));
485    }
486
487    #[test]
488    fn test_move_block_to_tab_auto_close() {
489        let store = make_store();
490        let ws = create_workspace(&store, "WS").unwrap();
491        let tab1 = create_tab(&store, &ws.oid).unwrap();
492        let tab2 = create_tab(&store, &ws.oid).unwrap();
493
494        let block = create_block(&store, &tab1.oid, MetaMapType::new()).unwrap();
495
496        // Move with auto_close=true — tab1 should be deleted since it becomes empty
497        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, true).unwrap();
498
499        // tab1 should be deleted
500        assert!(store.get::<Tab>(&tab1.oid).unwrap().is_none());
501
502        // workspace should only have tab2
503        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
504        assert_eq!(ws.tabids.len(), 1);
505        assert_eq!(ws.tabids[0], tab2.oid);
506    }
507
508    #[test]
509    fn test_move_block_same_tab_noop() {
510        let store = make_store();
511        let ws = create_workspace(&store, "WS").unwrap();
512        let tab = create_tab(&store, &ws.oid).unwrap();
513        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
514
515        // Moving to same tab should be a no-op
516        move_block_to_tab(&store, &block.oid, &tab.oid, &tab.oid, &ws.oid, false).unwrap();
517
518        let t = store.must_get::<Tab>(&tab.oid).unwrap();
519        assert_eq!(t.blockids.len(), 1);
520    }
521
522    #[test]
523    fn test_promote_block_to_tab() {
524        let store = make_store();
525        let ws = create_workspace(&store, "WS").unwrap();
526        let tab = create_tab(&store, &ws.oid).unwrap();
527        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
528
529        // Promote block to new tab
530        let new_tab = promote_block_to_tab(&store, &block.oid, &tab.oid, &ws.oid, false).unwrap();
531
532        // Original tab should be empty
533        let old_tab = store.must_get::<Tab>(&tab.oid).unwrap();
534        assert!(old_tab.blockids.is_empty());
535
536        // New tab should have the block
537        let nt = store.must_get::<Tab>(&new_tab.oid).unwrap();
538        assert_eq!(nt.blockids.len(), 1);
539        assert_eq!(nt.blockids[0], block.oid);
540
541        // Workspace should have both tabs
542        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
543        assert_eq!(ws.tabids.len(), 2);
544
545        // New tab should be active
546        assert_eq!(ws.activetabid, new_tab.oid);
547    }
548
549    #[test]
550    fn test_reorder_tab() {
551        let store = make_store();
552        let ws = create_workspace(&store, "WS").unwrap();
553        let tab1 = create_tab(&store, &ws.oid).unwrap();
554        let tab2 = create_tab(&store, &ws.oid).unwrap();
555        let tab3 = create_tab(&store, &ws.oid).unwrap();
556
557        // Verify initial order: [tab1, tab2, tab3]
558        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
559        assert_eq!(ws_data.tabids, vec![tab1.oid.clone(), tab2.oid.clone(), tab3.oid.clone()]);
560
561        // Move tab3 to index 0
562        reorder_tab(&store, &ws.oid, &tab3.oid, 0).unwrap();
563
564        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
565        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab1.oid.clone(), tab2.oid.clone()]);
566
567        // Move tab1 to end (index 99 should clamp to len)
568        reorder_tab(&store, &ws.oid, &tab1.oid, 99).unwrap();
569
570        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
571        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab2.oid.clone(), tab1.oid.clone()]);
572    }
573
574    #[test]
575    fn test_move_tab_to_workspace() {
576        let store = make_store();
577        let ws1 = create_workspace(&store, "WS1").unwrap();
578        let ws2 = create_workspace(&store, "WS2").unwrap();
579        let tab1 = create_tab(&store, &ws1.oid).unwrap();
580        let tab2 = create_tab(&store, &ws1.oid).unwrap();
581        let tab3 = create_tab(&store, &ws2.oid).unwrap();
582
583        // Set tab1 as active in ws1
584        set_active_tab(&store, &ws1.oid, &tab1.oid).unwrap();
585
586        // Move tab2 from ws1 to ws2
587        move_tab_to_workspace(&store, &tab2.oid, &ws1.oid, &ws2.oid, None).unwrap();
588
589        let ws1_data = store.must_get::<Workspace>(&ws1.oid).unwrap();
590        let ws2_data = store.must_get::<Workspace>(&ws2.oid).unwrap();
591
592        assert_eq!(ws1_data.tabids, vec![tab1.oid.clone()]);
593        assert!(ws2_data.tabids.contains(&tab2.oid));
594        assert!(ws2_data.tabids.contains(&tab3.oid));
595        // Moved tab becomes active in destination
596        assert_eq!(ws2_data.activetabid, tab2.oid);
597    }
598
599    #[test]
600    fn test_move_tab_to_workspace_last_tab_blocked() {
601        let store = make_store();
602        let ws1 = create_workspace(&store, "WS1").unwrap();
603        let ws2 = create_workspace(&store, "WS2").unwrap();
604        let tab1 = create_tab(&store, &ws1.oid).unwrap();
605        let _tab2 = create_tab(&store, &ws2.oid).unwrap();
606
607        // Should fail — can't move the only tab out
608        let result = move_tab_to_workspace(&store, &tab1.oid, &ws1.oid, &ws2.oid, None);
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn test_tear_off_block() {
614        let store = make_store();
615        let ws = create_workspace(&store, "WS").unwrap();
616        let tab = create_tab(&store, &ws.oid).unwrap();
617        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
618        let block2 = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
619
620        // Tear off block (not auto_close because tab still has block2)
621        let new_ws = tear_off_block(&store, &block.oid, &tab.oid, &ws.oid, true).unwrap();
622
623        // Block removed from source tab
624        let tab_data = store.must_get::<Tab>(&tab.oid).unwrap();
625        assert!(!tab_data.blockids.contains(&block.oid));
626        assert!(tab_data.blockids.contains(&block2.oid));
627
628        // New workspace created with the block
629        assert!(!new_ws.oid.is_empty());
630        assert_eq!(new_ws.tabids.len(), 1);
631        let new_tab = store.must_get::<Tab>(&new_ws.tabids[0]).unwrap();
632        assert!(new_tab.blockids.contains(&block.oid));
633
634        // Block parent ref updated
635        let block_data = store.must_get::<Block>(&block.oid).unwrap();
636        assert_eq!(block_data.parentoref, format!("tab:{}", new_tab.oid));
637    }
638
639    #[test]
640    fn test_tear_off_tab() {
641        let store = make_store();
642        let ws = create_workspace(&store, "WS").unwrap();
643        let tab1 = create_tab(&store, &ws.oid).unwrap();
644        let tab2 = create_tab(&store, &ws.oid).unwrap();
645        set_active_tab(&store, &ws.oid, &tab1.oid).unwrap();
646
647        // Tear off tab2
648        let new_ws = tear_off_tab(&store, &tab2.oid, &ws.oid).unwrap();
649
650        // Source workspace no longer has tab2
651        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
652        assert_eq!(ws_data.tabids, vec![tab1.oid.clone()]);
653
654        // New workspace has tab2
655        assert_eq!(new_ws.tabids, vec![tab2.oid.clone()]);
656        assert_eq!(new_ws.activetabid, tab2.oid);
657    }
658
659    #[test]
660    fn test_tear_off_last_tab_blocked() {
661        let store = make_store();
662        let ws = create_workspace(&store, "WS").unwrap();
663        let tab1 = create_tab(&store, &ws.oid).unwrap();
664
665        // Should fail — can't tear off the only tab
666        let result = tear_off_tab(&store, &tab1.oid, &ws.oid);
667        assert!(result.is_err());
668    }
669}