agentmux_srv\backend\wcore/
tab.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tab CRUD and reorder operations.
5
6use uuid::Uuid;
7
8use crate::backend::storage::wstore::WaveStore;
9use crate::backend::storage::StoreError;
10use crate::backend::obj::*;
11
12/// Create a new tab in a workspace.
13/// If `tab_name` is empty, auto-generates "Untitled1", "Untitled2", etc.
14/// If `pinned` is true, the tab goes into `pinnedtabids` instead of `tabids`.
15pub fn create_tab(store: &WaveStore, ws_id: &str) -> Result<Tab, StoreError> {
16    create_tab_with_opts(store, ws_id, "", false)
17}
18
19/// Create a new tab with explicit name and pinned options.
20pub fn create_tab_with_opts(
21    store: &WaveStore,
22    ws_id: &str,
23    tab_name: &str,
24    pinned: bool,
25) -> Result<Tab, StoreError> {
26    let mut ws = store.must_get::<Workspace>(ws_id)?;
27
28    // Auto-generate tab name if not provided. Plain "tabN" (per
29    // SPEC_TAB_GAPS_AND_NAMING_2026_04_25). Existing tabs named
30    // "Untitled1" / "T1" from older sessions are not migrated; this
31    // only affects newly-created tabs from this build forward.
32    let name = if tab_name.is_empty() {
33        format!("tab{}", ws.tabids.len() + ws.pinnedtabids.len() + 1)
34    } else {
35        tab_name.to_string()
36    };
37
38    // Create layout state for the tab
39    let mut layout = LayoutState {
40        oid: Uuid::new_v4().to_string(),
41        rootnode: None,
42        magnifiednodeid: String::new(),
43        focusednodeid: String::new(),
44        leaforder: None,
45        pendingbackendactions: None,
46        meta: None,
47        ..Default::default()
48    };
49    store.insert(&mut layout)?;
50
51    let mut tab = Tab {
52        oid: Uuid::new_v4().to_string(),
53        name,
54        layoutstate: layout.oid.clone(),
55        blockids: vec![],
56        meta: MetaMapType::new(),
57        ..Default::default()
58    };
59    store.insert(&mut tab)?;
60
61    // Add tab to workspace (pinned or unpinned) and set as active
62    if pinned {
63        ws.pinnedtabids.push(tab.oid.clone());
64    } else {
65        ws.tabids.push(tab.oid.clone());
66    }
67    if ws.activetabid.is_empty() {
68        ws.activetabid = tab.oid.clone();
69    }
70    store.update(&mut ws)?;
71
72    Ok(tab)
73}
74
75/// Delete a tab and its blocks/layout.
76pub fn delete_tab(
77    store: &WaveStore,
78    ws_id: &str,
79    tab_id: &str,
80) -> Result<(), StoreError> {
81    let mut ws = store.must_get::<Workspace>(ws_id)?;
82
83    // Remove tab from workspace
84    ws.tabids.retain(|id| id != tab_id);
85    ws.pinnedtabids.retain(|id| id != tab_id);
86
87    // If active tab was deleted, pick a new one
88    if ws.activetabid == tab_id {
89        ws.activetabid = ws.tabids.first().cloned().unwrap_or_default();
90    }
91    store.update(&mut ws)?;
92
93    delete_tab_inner(store, tab_id)?;
94    Ok(())
95}
96
97/// Internal: delete a tab's layout and blocks, then the tab itself.
98/// Kills shell processes for all blocks before deleting from the database.
99pub(super) fn delete_tab_inner(store: &WaveStore, tab_id: &str) -> Result<(), StoreError> {
100    if let Ok(tab) = store.must_get::<Tab>(tab_id) {
101        // Kill shell processes FIRST — must happen before DB cleanup
102        for block_id in &tab.blockids {
103            crate::backend::blockcontroller::delete_controller(block_id);
104        }
105        // Delete layout state
106        if !tab.layoutstate.is_empty() {
107            let _ = store.delete::<LayoutState>(&tab.layoutstate);
108        }
109        // Delete all blocks in the tab
110        for block_id in &tab.blockids {
111            let _ = store.delete::<Block>(block_id);
112        }
113    }
114    let _ = store.delete::<Tab>(tab_id);
115    Ok(())
116}
117
118/// Set the active tab in a workspace.
119pub fn set_active_tab(
120    store: &WaveStore,
121    ws_id: &str,
122    tab_id: &str,
123) -> Result<(), StoreError> {
124    let mut ws = store.must_get::<Workspace>(ws_id)?;
125    let tab_str = tab_id.to_string();
126    if !ws.tabids.contains(&tab_str) && !ws.pinnedtabids.contains(&tab_str) {
127        return Err(StoreError::NotFound);
128    }
129    ws.activetabid = tab_str;
130    store.update(&mut ws)?;
131    Ok(())
132}
133
134/// Reorder a tab within a workspace by moving it to a new index.
135pub fn reorder_tab(
136    store: &WaveStore,
137    ws_id: &str,
138    tab_id: &str,
139    new_index: usize,
140) -> Result<(), StoreError> {
141    tracing::info!(ws_id = %ws_id, tab_id = %tab_id, new_index = %new_index, "[dnd] reorder_tab");
142    let mut ws = store.must_get::<Workspace>(ws_id)?;
143
144    // Determine if tab is in regular or pinned list
145    if let Some(pos) = ws.tabids.iter().position(|id| id == tab_id) {
146        ws.tabids.remove(pos);
147        let insert_at = new_index.min(ws.tabids.len());
148        ws.tabids.insert(insert_at, tab_id.to_string());
149    } else if let Some(pos) = ws.pinnedtabids.iter().position(|id| id == tab_id) {
150        ws.pinnedtabids.remove(pos);
151        let insert_at = new_index.min(ws.pinnedtabids.len());
152        ws.pinnedtabids.insert(insert_at, tab_id.to_string());
153    } else {
154        return Err(StoreError::NotFound);
155    }
156
157    store.update(&mut ws)?;
158    tracing::info!(tab_id = %tab_id, new_index = %new_index, "[dnd] reorder_tab complete");
159    Ok(())
160}