Skip to main content

agentmux_srv/backend/wcore/
mod.rs

1#![allow(dead_code)]
2// Copyright 2025, Command Line Inc.
3// Copyright 2025-2026, AgentMux Corp.
4// SPDX-License-Identifier: Apache-2.0
5
6//! AgentMux Core: application coordinator for storage + pub/sub.
7//! Port of Go's pkg/wcore/wcore.go + window.go + workspace.go + block.go.
8//!
9//! Orchestrates Store mutations with MPS event publishing.
10
11mod block;
12mod dnd;
13mod event;
14mod tab;
15mod window;
16mod workspace;
17
18// Re-export all public APIs so callers can continue using `wcore::function_name`.
19pub use block::*;
20#[allow(unused_imports)]
21pub use event::*;
22pub use tab::*;
23pub use window::*;
24pub use workspace::*;
25
26use uuid::Uuid;
27
28use super::storage::store::Store;
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: &Store) -> 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 mut win = create_window(store, &ws.oid)?;
89
90    // Seed the window's display name (drives the OS/taskbar title) from
91    // AGENTMUX_WINDOW_NAME if the launcher/caller set it. This lets an agent
92    // bring up a recognizable instance, e.g. `AGENTMUX_WINDOW_NAME="repro #1503"
93    // task dev`. Only applies to a fresh instance's first window (this
94    // initial-data path); the runtime `SetWindowName` verb renames later.
95    // See SPEC_AGENT_API_FIRST_CLASS_SURFACE_2026_06_17.md §4.4.
96    if let Ok(name) = std::env::var("AGENTMUX_WINDOW_NAME") {
97        let name = name.trim();
98        if !name.is_empty() {
99            win.meta.insert(
100                "window:displayname".to_string(),
101                serde_json::json!(name.chars().take(64).collect::<String>()),
102            );
103            store.update(&mut win)?;
104        }
105    }
106
107    // Update client with window ID
108    client.windowids.push(win.oid.clone());
109    store.update(&mut client)?;
110
111    // Create initial tab in workspace (pinned, matching Go's isInitialLaunch=true)
112    let tab = create_tab_with_opts(store, &ws.oid, "", true)?;
113
114    // Seed the default 3-pane launch layout (agent + sysinfo + swarm).
115    // Shared with the new-window path so "Open another window"
116    // matches first launch.
117    seed_default_layout(store, &tab.oid)?;
118
119    Ok(first_launch)
120}
121
122/// Seed a tab with the default 3-pane launch layout (agent + sysinfo + swarm):
123///
124/// ```text
125///   ┌────────────────┬──────────────┐
126///   │                │   sysinfo    │  size 2 of 10 ≈ 20%
127///   │     agent      ├──────────────┤
128///   │    (tall)      │              │
129///   │                │    swarm     │  size 8 of 10 ≈ 80%
130///   │                │              │
131///   └────────────────┴──────────────┘
132///        50% width         50% width
133/// ```
134///
135/// Connectors and Memory aren't in the starter set (each is one click away in
136/// the widget bar) and sysinfo moved above swarm, keeping its previous 20% share
137/// of the right column. Supersedes the 4-pane arrangement from
138/// SPEC_DEFAULT_WIDGETS_REORDER_2026_08_25.md. Only affects tabs seeded from
139/// here — first launch and newly-created windows; an existing persisted
140/// layout is untouched.
141///
142/// Shared by first-launch bootstrap (`ensure_initial_data`) and the new-window
143/// path (`server::service`'s `CreateWindow` with an empty workspace) so "Open
144/// another window" gets the same starter layout instead of opening blank
145/// (regression — see docs/retro/retro-blank-new-window-2026-06-21.md). The tab
146/// must already exist with a valid `layoutstate`; both `create_tab_with_opts`
147/// and the reducer's `apply_tab_created` provision one.
148///
149/// `rootnode` is built directly rather than queued via `pendingbackendactions`:
150/// the frontend reducer races when draining multiple insert+split actions in one
151/// cycle (`layoutPersistence.ts` + `layoutNodeModels.ts::getNodeByBlockId`); a
152/// pre-built tree skips the reducer entirely and just renders.
153pub(crate) fn seed_default_layout(store: &Store, tab_id: &str) -> Result<(), StoreError> {
154    let tab = store.must_get::<Tab>(tab_id)?;
155
156    let mut agent_meta = MetaMapType::new();
157    agent_meta.insert("view".to_string(), serde_json::json!("agent"));
158    let agent_block = create_block(store, &tab.oid, agent_meta)?;
159
160    let mut swarm_meta = MetaMapType::new();
161    swarm_meta.insert("view".to_string(), serde_json::json!("swarm"));
162    let swarm_block = create_block(store, &tab.oid, swarm_meta)?;
163
164    let mut sysinfo_meta = MetaMapType::new();
165    sysinfo_meta.insert("view".to_string(), serde_json::json!("sysinfo"));
166    let sysinfo_block = create_block(store, &tab.oid, sysinfo_meta)?;
167
168    write_default_three_pane_layout(store, tab_id, &agent_block.oid, &swarm_block.oid, &sysinfo_block.oid)
169}
170
171/// Write the default 3-pane launch layout (`agent | [sysinfo / swarm]`) into
172/// `tab_id`'s `LayoutState`, wrapping three block IDs that the caller has
173/// ALREADY created.
174///
175/// Split out of `seed_default_layout` for the 2nd-window-tear-off desync fix
176/// (#1681). `seed_default_layout` creates its blocks store-only via
177/// `create_block`, which is correct for first-launch (`ensure_initial_data`
178/// runs before bootstrap loads SQLite into the in-memory reducer `srv_state`).
179/// But the post-bootstrap "open another window" path (`service.rs` CreateWindow)
180/// must create the blocks THROUGH THE REDUCER so they also land in `srv_state`;
181/// otherwise the new window's blocks live only in SQLite, the frontend renders
182/// them, and `TearOffBlock` rejects them as "block not found" (the reducer never
183/// saw them). Both callers share this one function for the tree shape so the
184/// layout can never drift between the two paths.
185pub(crate) fn write_default_three_pane_layout(
186    store: &Store,
187    tab_id: &str,
188    agent_block_id: &str,
189    swarm_block_id: &str,
190    sysinfo_block_id: &str,
191) -> Result<(), StoreError> {
192    let tab = store.must_get::<Tab>(tab_id)?;
193    let (rootnode, focused_node_id, leaforder) =
194        default_three_pane_tree(agent_block_id, swarm_block_id, sysinfo_block_id);
195    let mut layout = store.must_get::<LayoutState>(&tab.layoutstate)?;
196    layout.rootnode = Some(rootnode);
197    layout.focusednodeid = focused_node_id;
198    layout.leaforder = Some(leaforder);
199    layout.pendingbackendactions = None;
200    store.update(&mut layout)?;
201    Ok(())
202}
203
204/// Pure builder for the default 3-pane tree (`agent | [sysinfo / swarm]`).
205/// Returns `(rootnode, focused_node_id, leaforder)`. Shared by
206/// the pre-bootstrap store-direct writer above (first launch — reducer not
207/// hydrated yet) and the post-bootstrap reducer-routed CreateWindow seed
208/// (SPEC_864 Phase 3, `seed_layout_via_reducer`), so the layout shape can
209/// never drift between the two paths.
210///
211/// Parameter order is semantic (agent/swarm/sysinfo), NOT top-to-bottom
212/// display order — sysinfo renders above swarm in the right column. Callers
213/// that build their block list positionally must keep passing them by
214/// meaning, not by visual position.
215pub(crate) fn default_three_pane_tree(
216    agent_block_id: &str,
217    swarm_block_id: &str,
218    sysinfo_block_id: &str,
219) -> (LayoutNode, String, Vec<LeafOrderEntry>) {
220    // Node IDs for each tree position. Leaves get their own node IDs distinct
221    // from the block IDs they wrap.
222    let agent_node_id = Uuid::new_v4().to_string();
223    let swarm_node_id = Uuid::new_v4().to_string();
224    let sysinfo_node_id = Uuid::new_v4().to_string();
225    let right_col_id = Uuid::new_v4().to_string();
226    let root_id = Uuid::new_v4().to_string();
227
228    // Phase E.4.B Phase 2 — typed LayoutNode (was inline JSON).
229    let rootnode = LayoutNode {
230        id: root_id,
231        flex_direction: FlexDirection::Row,
232        size: 10.0,
233        data: None,
234        children: vec![
235            LayoutNode {
236                id: agent_node_id.clone(),
237                flex_direction: FlexDirection::Column,
238                size: 5.0,
239                children: Vec::new(),
240                data: Some(LayoutNodeData {
241                    block_id: agent_block_id.to_string(),
242                    ..Default::default()
243                }),
244                ..Default::default()
245            },
246            LayoutNode {
247                id: right_col_id,
248                flex_direction: FlexDirection::Column,
249                size: 5.0,
250                children: vec![
251                    // sysinfo (CPU) sits ABOVE swarm, keeping the same 20%
252                    // share of the right column it had in the 4-pane layout.
253                    LayoutNode {
254                        id: sysinfo_node_id.clone(),
255                        flex_direction: FlexDirection::Row,
256                        size: 2.0,
257                        children: Vec::new(),
258                        data: Some(LayoutNodeData {
259                            block_id: sysinfo_block_id.to_string(),
260                            ..Default::default()
261                        }),
262                        ..Default::default()
263                    },
264                    LayoutNode {
265                        id: swarm_node_id.clone(),
266                        flex_direction: FlexDirection::Row,
267                        size: 8.0,
268                        children: Vec::new(),
269                        data: Some(LayoutNodeData {
270                            block_id: swarm_block_id.to_string(),
271                            ..Default::default()
272                        }),
273                        ..Default::default()
274                    },
275                ],
276                data: None,
277                ..Default::default()
278            },
279        ],
280        ..Default::default()
281    };
282
283    // Display order (agent, then the right column top-to-bottom).
284    let leaforder = vec![
285        LeafOrderEntry { nodeid: agent_node_id.clone(), blockid: agent_block_id.to_string() },
286        LeafOrderEntry { nodeid: sysinfo_node_id, blockid: sysinfo_block_id.to_string() },
287        LeafOrderEntry { nodeid: swarm_node_id, blockid: swarm_block_id.to_string() },
288    ];
289    (rootnode, agent_node_id, leaforder)
290}
291
292/// Pure builder for a single-pane tree wrapping exactly one block — no
293/// splits. Used by the post-bootstrap CreateWindow seed
294/// (`handle_create_window`) when the caller supplies a specific `seed_view`
295/// (e.g. "Open in New Window" on a widget) instead of wanting the default
296/// 3-pane launch layout — the new window's sole pane is the requested
297/// widget, not that widget plus the default agent/swarm/sysinfo split.
298pub(crate) fn single_leaf_tree(block_id: &str) -> (LayoutNode, String, Vec<LeafOrderEntry>) {
299    let node_id = Uuid::new_v4().to_string();
300    let rootnode = LayoutNode {
301        id: node_id.clone(),
302        flex_direction: FlexDirection::Column,
303        size: 10.0,
304        children: Vec::new(),
305        data: Some(LayoutNodeData {
306            block_id: block_id.to_string(),
307            ..Default::default()
308        }),
309        ..Default::default()
310    };
311    let leaforder = vec![LeafOrderEntry { nodeid: node_id.clone(), blockid: block_id.to_string() }];
312    (rootnode, node_id, leaforder)
313}
314
315/// Get the singleton client record.
316pub fn get_client(store: &Store) -> Result<Client, StoreError> {
317    let clients = store.get_all::<Client>()?;
318    clients.into_iter().next().ok_or(StoreError::NotFound)
319}
320
321// ====================================================================
322// Tests
323// ====================================================================
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use super::dnd::*;
329
330    fn make_store() -> Store {
331        Store::open_in_memory().unwrap()
332    }
333
334    #[test]
335    fn test_ensure_initial_data_first_launch() {
336        let store = make_store();
337        let first = ensure_initial_data(&store).unwrap();
338        assert!(first);
339
340        // Should have created client, window, workspace, tab
341        let client = get_client(&store).unwrap();
342        assert_eq!(client.windowids.len(), 1);
343        assert!(!client.tempoid.is_empty());
344
345        let windows = store.get_all::<Window>().unwrap();
346        assert_eq!(windows.len(), 1);
347        // Window should have pos:{0,0} and winsize:{0,0} (matching Go)
348        assert_eq!(windows[0].pos.x, 0);
349        assert_eq!(windows[0].pos.y, 0);
350        assert_eq!(windows[0].winsize.width, 0);
351        assert_eq!(windows[0].winsize.height, 0);
352
353        let workspaces = store.get_all::<Workspace>().unwrap();
354        assert_eq!(workspaces.len(), 1);
355        assert_eq!(workspaces[0].name, "Starter workspace");
356        // Starter tab should be pinned (matching Go's isInitialLaunch=true)
357        assert_eq!(workspaces[0].pinnedtabids.len(), 1);
358        assert_eq!(workspaces[0].tabids.len(), 0);
359
360        let tabs = store.get_all::<Tab>().unwrap();
361        assert_eq!(tabs.len(), 1);
362        // Tab should be named "Tab 1" (per SPEC_TAB_GAPS_AND_NAMING_2026_04_25 —
363        // auto-generated tabs use the `Tab N` convention, not the older
364        // `Untitled1` name; revised from plain `tabN` 2026-09-02).
365        assert_eq!(tabs[0].name, "Tab 1");
366    }
367
368    #[test]
369    fn test_ensure_initial_data_idempotent() {
370        let store = make_store();
371        let first = ensure_initial_data(&store).unwrap();
372        assert!(first);
373
374        let second = ensure_initial_data(&store).unwrap();
375        assert!(!second);
376
377        // Should still have exactly 1 client
378        assert_eq!(store.count::<Client>().unwrap(), 1);
379    }
380
381    #[test]
382    fn test_create_and_delete_workspace() {
383        let store = make_store();
384        let ws = create_workspace(&store, "Test WS").unwrap();
385        assert_eq!(ws.name, "Test WS");
386
387        // Create tabs in workspace
388        let t1 = create_tab(&store, &ws.oid).unwrap();
389        let t2 = create_tab(&store, &ws.oid).unwrap();
390
391        let ws = get_workspace(&store, &ws.oid).unwrap();
392        assert_eq!(ws.tabids.len(), 2);
393
394        // Delete workspace cascades to tabs
395        let t1_oid = t1.oid.clone();
396        let t2_oid = t2.oid.clone();
397        delete_workspace(&store, &ws.oid).unwrap();
398        assert!(store.get::<Workspace>(&ws.oid).unwrap().is_none());
399        assert!(store.get::<Tab>(&t1_oid).unwrap().is_none());
400        assert!(store.get::<Tab>(&t2_oid).unwrap().is_none());
401    }
402
403    #[test]
404    fn test_create_and_delete_tab() {
405        let store = make_store();
406        let ws = create_workspace(&store, "WS").unwrap();
407        let tab1 = create_tab(&store, &ws.oid).unwrap();
408        let tab2 = create_tab(&store, &ws.oid).unwrap();
409
410        let ws = get_workspace(&store, &ws.oid).unwrap();
411        assert_eq!(ws.tabids.len(), 2);
412        assert_eq!(ws.activetabid, tab1.oid);
413
414        // Delete active tab — active should switch to tab2
415        delete_tab(&store, &ws.oid, &tab1.oid).unwrap();
416        let ws = get_workspace(&store, &ws.oid).unwrap();
417        assert_eq!(ws.tabids.len(), 1);
418        assert_eq!(ws.activetabid, tab2.oid);
419    }
420
421    #[test]
422    fn test_set_active_tab() {
423        let store = make_store();
424        let ws = create_workspace(&store, "WS").unwrap();
425        let _tab1 = create_tab(&store, &ws.oid).unwrap();
426        let tab2 = create_tab(&store, &ws.oid).unwrap();
427
428        set_active_tab(&store, &ws.oid, &tab2.oid).unwrap();
429        let ws = get_workspace(&store, &ws.oid).unwrap();
430        assert_eq!(ws.activetabid, tab2.oid);
431
432        // Setting non-existent tab should fail
433        let result = set_active_tab(&store, &ws.oid, "nonexistent");
434        assert!(result.is_err());
435    }
436
437    #[test]
438    fn test_create_and_delete_block() {
439        let store = make_store();
440        let ws = create_workspace(&store, "WS").unwrap();
441        let tab = create_tab(&store, &ws.oid).unwrap();
442
443        let mut meta = MetaMapType::new();
444        meta.insert("view".to_string(), serde_json::json!("term"));
445        let block = create_block(&store, &tab.oid, meta).unwrap();
446
447        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
448        assert_eq!(tab.blockids.len(), 1);
449        assert_eq!(tab.blockids[0], block.oid);
450
451        let loaded = store.must_get::<Block>(&block.oid).unwrap();
452        assert_eq!(loaded.parentoref, format!("tab:{}", tab.oid));
453        assert_eq!(loaded.meta.get("view").unwrap(), "term");
454
455        delete_block(&store, &tab.oid, &block.oid).unwrap();
456        assert!(store.get::<Block>(&block.oid).unwrap().is_none());
457        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
458        assert!(tab.blockids.is_empty());
459    }
460
461    #[test]
462    fn seed_default_layout_creates_three_pane_layout() {
463        // Regression: "Open another window" opened blank because new windows
464        // never received the default layout (only first launch did). This is
465        // the shared primitive both paths now use.
466        // See docs/retro/retro-blank-new-window-2026-06-21.md.
467        let store = make_store();
468        let ws = create_workspace(&store, "WS").unwrap();
469        let tab = create_tab(&store, &ws.oid).unwrap();
470        assert!(tab.blockids.is_empty(), "fresh tab starts blank");
471        let layout = store.must_get::<LayoutState>(&tab.layoutstate).unwrap();
472        assert!(layout.rootnode.is_none(), "fresh tab has no layout");
473
474        seed_default_layout(&store, &tab.oid).unwrap();
475
476        // 3 blocks: agent + swarm + sysinfo. Connectors and Memory are deliberately
477        // NOT seeded — both stay reachable from the widget bar.
478        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
479        assert_eq!(tab.blockids.len(), 3, "agent + swarm + sysinfo");
480        let views: Vec<String> = tab
481            .blockids
482            .iter()
483            .map(|bid| {
484                store
485                    .must_get::<Block>(bid)
486                    .unwrap()
487                    .meta
488                    .get("view")
489                    .and_then(|v| v.as_str())
490                    .unwrap_or_default()
491                    .to_string()
492            })
493            .collect();
494        assert!(views.contains(&"agent".to_string()));
495        assert!(views.contains(&"swarm".to_string()));
496        assert!(views.contains(&"sysinfo".to_string()));
497        for view in ["connectors", "memory"] {
498            assert!(!views.contains(&view.to_string()), "{view} must not be seeded");
499        }
500
501        // Layout populated (the regression was new windows getting rootnode=None).
502        let layout = store.must_get::<LayoutState>(&tab.layoutstate).unwrap();
503        assert!(layout.rootnode.is_some(), "rootnode must be populated, not blank");
504        assert_eq!(layout.leaforder.as_ref().unwrap().len(), 3);
505    }
506
507    /// The right column is `[sysinfo, swarm]` top-to-bottom — CPU above
508    /// swarm, not below it — and sysinfo keeps the 20% share it had in the
509    /// old 4-pane layout. Asserted on the tree directly, since `leaforder`
510    /// alone wouldn't catch the two being swapped in `children`.
511    #[test]
512    fn default_three_pane_tree_puts_sysinfo_above_swarm() {
513        let (root, focused, leaforder) = default_three_pane_tree("b-agent", "b-swarm", "b-sysinfo");
514
515        assert_eq!(root.children.len(), 2, "agent | right-column");
516        let agent = &root.children[0];
517        assert_eq!(agent.data.as_ref().unwrap().block_id, "b-agent");
518        assert_eq!(focused, agent.id, "agent pane starts focused");
519
520        let right = &root.children[1];
521        assert_eq!(right.flex_direction, FlexDirection::Column);
522        assert_eq!(right.children.len(), 2, "sysinfo + swarm only");
523        assert_eq!(right.children[0].data.as_ref().unwrap().block_id, "b-sysinfo", "sysinfo on top");
524        assert_eq!(right.children[1].data.as_ref().unwrap().block_id, "b-swarm", "swarm below");
525        assert_eq!(right.children[0].size, 2.0);
526        assert_eq!(right.children[1].size, 8.0);
527
528        let order: Vec<&str> = leaforder.iter().map(|e| e.blockid.as_str()).collect();
529        assert_eq!(order, vec!["b-agent", "b-sysinfo", "b-swarm"]);
530    }
531
532    #[test]
533    fn single_leaf_tree_wraps_exactly_one_block_with_no_split() {
534        let (root, focused, leaforder) = single_leaf_tree("b-widget");
535
536        assert!(root.children.is_empty(), "no split — the root node IS the leaf");
537        assert_eq!(root.data.as_ref().unwrap().block_id, "b-widget");
538        assert_eq!(focused, root.id, "the sole pane starts focused");
539        assert_eq!(leaforder.len(), 1);
540        assert_eq!(leaforder[0].blockid, "b-widget");
541        assert_eq!(leaforder[0].nodeid, root.id);
542    }
543
544    #[test]
545    fn test_create_and_close_window() {
546        let store = make_store();
547        ensure_initial_data(&store).unwrap();
548
549        let client = get_client(&store).unwrap();
550        let initial_count = client.windowids.len();
551
552        let ws = create_workspace(&store, "WS2").unwrap();
553        let window = create_window(&store, &ws.oid).unwrap();
554
555        // Add to client
556        let mut client = get_client(&store).unwrap();
557        client.windowids.push(window.oid.clone());
558        store.update(&mut client).unwrap();
559
560        close_window(&store, &window.oid).unwrap();
561        let client = get_client(&store).unwrap();
562        assert_eq!(client.windowids.len(), initial_count);
563        assert!(store.get::<Window>(&window.oid).unwrap().is_none());
564    }
565
566    #[test]
567    fn test_focus_window() {
568        let store = make_store();
569        ensure_initial_data(&store).unwrap();
570
571        let ws = create_workspace(&store, "WS2").unwrap();
572        let w2 = create_window(&store, &ws.oid).unwrap();
573        let mut client = get_client(&store).unwrap();
574        client.windowids.push(w2.oid.clone());
575        store.update(&mut client).unwrap();
576
577        // w2 should be last, focus should move it first
578        focus_window(&store, &w2.oid).unwrap();
579        let client = get_client(&store).unwrap();
580        assert_eq!(client.windowids[0], w2.oid);
581    }
582
583    #[test]
584    fn test_switch_workspace() {
585        let store = make_store();
586        ensure_initial_data(&store).unwrap();
587
588        let client = get_client(&store).unwrap();
589        let window_id = &client.windowids[0];
590        let window = store.must_get::<Window>(window_id).unwrap();
591        let old_ws = window.workspaceid.clone();
592
593        let new_ws = create_workspace(&store, "New WS").unwrap();
594        switch_workspace(&store, window_id, &new_ws.oid).unwrap();
595
596        let window = store.must_get::<Window>(window_id).unwrap();
597        assert_eq!(window.workspaceid, new_ws.oid);
598        assert_ne!(window.workspaceid, old_ws);
599    }
600
601    #[test]
602    fn test_resolve_block_id_prefix() {
603        let store = make_store();
604        let ws = create_workspace(&store, "WS").unwrap();
605        let tab = create_tab(&store, &ws.oid).unwrap();
606
607        let meta = MetaMapType::new();
608        let block = create_block(&store, &tab.oid, meta).unwrap();
609        let prefix = &block.oid[..8];
610
611        let resolved = resolve_block_id_from_prefix(&store, &tab.oid, prefix).unwrap();
612        assert_eq!(resolved, block.oid);
613
614        // Non-matching prefix
615        let result = resolve_block_id_from_prefix(&store, &tab.oid, "00000000");
616        assert!(result.is_err());
617    }
618
619    #[test]
620    fn test_list_workspaces() {
621        let store = make_store();
622        create_workspace(&store, "WS1").unwrap();
623        create_workspace(&store, "WS2").unwrap();
624        create_workspace(&store, "WS3").unwrap();
625
626        let all = list_workspaces(&store).unwrap();
627        assert_eq!(all.len(), 3);
628    }
629
630    #[test]
631    fn test_check_and_fix_window_missing_workspace() {
632        let store = make_store();
633        // Create a window pointing to a non-existent workspace
634        let mut window = Window {
635            oid: Uuid::new_v4().to_string(),
636            workspaceid: "nonexistent".to_string(),
637            pos: Point { x: 0, y: 0 },
638            winsize: WinSize {
639                width: 800,
640                height: 600,
641            },
642            meta: MetaMapType::new(),
643            ..Default::default()
644        };
645        store.insert(&mut window).unwrap();
646
647        window::check_and_fix_window(&store, &window.oid).unwrap();
648
649        // Should have created a new workspace and pointed window to it
650        let fixed = store.must_get::<Window>(&window.oid).unwrap();
651        assert_ne!(fixed.workspaceid, "nonexistent");
652        let ws = store.must_get::<Workspace>(&fixed.workspaceid).unwrap();
653        assert_eq!(ws.tabids.len(), 1); // should have created a tab too
654    }
655
656    #[test]
657    fn test_move_block_to_tab() {
658        let store = make_store();
659        let ws = create_workspace(&store, "WS").unwrap();
660        let tab1 = create_tab(&store, &ws.oid).unwrap();
661        let tab2 = create_tab(&store, &ws.oid).unwrap();
662
663        let meta = MetaMapType::new();
664        let block = create_block(&store, &tab1.oid, meta).unwrap();
665
666        // Verify block is in tab1
667        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
668        assert_eq!(t1.blockids.len(), 1);
669        assert_eq!(t1.blockids[0], block.oid);
670
671        // Move block from tab1 to tab2
672        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, false).unwrap();
673
674        // tab1 should be empty, tab2 should have the block
675        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
676        let t2 = store.must_get::<Tab>(&tab2.oid).unwrap();
677        assert!(t1.blockids.is_empty());
678        assert_eq!(t2.blockids.len(), 1);
679        assert_eq!(t2.blockids[0], block.oid);
680
681        // Block parentoref should point to tab2
682        let b = store.must_get::<Block>(&block.oid).unwrap();
683        assert_eq!(b.parentoref, format!("tab:{}", tab2.oid));
684    }
685
686    #[test]
687    fn test_move_block_to_tab_auto_close() {
688        let store = make_store();
689        let ws = create_workspace(&store, "WS").unwrap();
690        let tab1 = create_tab(&store, &ws.oid).unwrap();
691        let tab2 = create_tab(&store, &ws.oid).unwrap();
692
693        let block = create_block(&store, &tab1.oid, MetaMapType::new()).unwrap();
694
695        // Move with auto_close=true — tab1 should be deleted since it becomes empty
696        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, true).unwrap();
697
698        // tab1 should be deleted
699        assert!(store.get::<Tab>(&tab1.oid).unwrap().is_none());
700
701        // workspace should only have tab2
702        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
703        assert_eq!(ws.tabids.len(), 1);
704        assert_eq!(ws.tabids[0], tab2.oid);
705    }
706
707    #[test]
708    fn test_move_block_same_tab_noop() {
709        let store = make_store();
710        let ws = create_workspace(&store, "WS").unwrap();
711        let tab = create_tab(&store, &ws.oid).unwrap();
712        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
713
714        // Moving to same tab should be a no-op
715        move_block_to_tab(&store, &block.oid, &tab.oid, &tab.oid, &ws.oid, false).unwrap();
716
717        let t = store.must_get::<Tab>(&tab.oid).unwrap();
718        assert_eq!(t.blockids.len(), 1);
719    }
720
721    #[test]
722    fn test_promote_block_to_tab() {
723        let store = make_store();
724        let ws = create_workspace(&store, "WS").unwrap();
725        let tab = create_tab(&store, &ws.oid).unwrap();
726        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
727
728        // Promote block to new tab
729        let new_tab = promote_block_to_tab(&store, &block.oid, &tab.oid, &ws.oid, false).unwrap();
730
731        // Original tab should be empty
732        let old_tab = store.must_get::<Tab>(&tab.oid).unwrap();
733        assert!(old_tab.blockids.is_empty());
734
735        // New tab should have the block
736        let nt = store.must_get::<Tab>(&new_tab.oid).unwrap();
737        assert_eq!(nt.blockids.len(), 1);
738        assert_eq!(nt.blockids[0], block.oid);
739
740        // Workspace should have both tabs
741        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
742        assert_eq!(ws.tabids.len(), 2);
743
744        // New tab should be active
745        assert_eq!(ws.activetabid, new_tab.oid);
746    }
747
748    #[test]
749    fn test_reorder_tab() {
750        let store = make_store();
751        let ws = create_workspace(&store, "WS").unwrap();
752        let tab1 = create_tab(&store, &ws.oid).unwrap();
753        let tab2 = create_tab(&store, &ws.oid).unwrap();
754        let tab3 = create_tab(&store, &ws.oid).unwrap();
755
756        // Verify initial order: [tab1, tab2, tab3]
757        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
758        assert_eq!(ws_data.tabids, vec![tab1.oid.clone(), tab2.oid.clone(), tab3.oid.clone()]);
759
760        // Move tab3 to index 0
761        reorder_tab(&store, &ws.oid, &tab3.oid, 0).unwrap();
762
763        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
764        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab1.oid.clone(), tab2.oid.clone()]);
765
766        // Move tab1 to end (index 99 should clamp to len)
767        reorder_tab(&store, &ws.oid, &tab1.oid, 99).unwrap();
768
769        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
770        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab2.oid.clone(), tab1.oid.clone()]);
771    }
772
773    #[test]
774    fn test_move_tab_to_workspace() {
775        let store = make_store();
776        let ws1 = create_workspace(&store, "WS1").unwrap();
777        let ws2 = create_workspace(&store, "WS2").unwrap();
778        let tab1 = create_tab(&store, &ws1.oid).unwrap();
779        let tab2 = create_tab(&store, &ws1.oid).unwrap();
780        let tab3 = create_tab(&store, &ws2.oid).unwrap();
781
782        // Set tab1 as active in ws1
783        set_active_tab(&store, &ws1.oid, &tab1.oid).unwrap();
784
785        // Move tab2 from ws1 to ws2
786        move_tab_to_workspace(&store, &tab2.oid, &ws1.oid, &ws2.oid, None).unwrap();
787
788        let ws1_data = store.must_get::<Workspace>(&ws1.oid).unwrap();
789        let ws2_data = store.must_get::<Workspace>(&ws2.oid).unwrap();
790
791        assert_eq!(ws1_data.tabids, vec![tab1.oid.clone()]);
792        assert!(ws2_data.tabids.contains(&tab2.oid));
793        assert!(ws2_data.tabids.contains(&tab3.oid));
794        // Moved tab becomes active in destination
795        assert_eq!(ws2_data.activetabid, tab2.oid);
796    }
797
798    #[test]
799    fn test_move_tab_to_workspace_last_tab_blocked() {
800        let store = make_store();
801        let ws1 = create_workspace(&store, "WS1").unwrap();
802        let ws2 = create_workspace(&store, "WS2").unwrap();
803        let tab1 = create_tab(&store, &ws1.oid).unwrap();
804        let _tab2 = create_tab(&store, &ws2.oid).unwrap();
805
806        // Should fail — can't move the only tab out
807        let result = move_tab_to_workspace(&store, &tab1.oid, &ws1.oid, &ws2.oid, None);
808        assert!(result.is_err());
809    }
810
811    #[test]
812    fn test_tear_off_tab() {
813        let store = make_store();
814        let ws = create_workspace(&store, "WS").unwrap();
815        let tab1 = create_tab(&store, &ws.oid).unwrap();
816        let tab2 = create_tab(&store, &ws.oid).unwrap();
817        set_active_tab(&store, &ws.oid, &tab1.oid).unwrap();
818
819        // Tear off tab2
820        let new_ws = tear_off_tab(&store, &tab2.oid, &ws.oid).unwrap();
821
822        // Source workspace no longer has tab2
823        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
824        assert_eq!(ws_data.tabids, vec![tab1.oid.clone()]);
825
826        // New workspace has tab2
827        assert_eq!(new_ws.tabids, vec![tab2.oid.clone()]);
828        assert_eq!(new_ws.activetabid, tab2.oid);
829    }
830
831    #[test]
832    fn test_tear_off_last_tab_blocked() {
833        let store = make_store();
834        let ws = create_workspace(&store, "WS").unwrap();
835        let tab1 = create_tab(&store, &ws.oid).unwrap();
836
837        // Should fail — can't tear off the only tab
838        let result = tear_off_tab(&store, &tab1.oid, &ws.oid);
839        assert!(result.is_err());
840    }
841}