Skip to main content

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