Skip to main content

agentmux_srv/backend/wcore/
dnd.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Drag-and-drop operations: moving/promoting/tearing-off blocks and tabs.
5
6use uuid::Uuid;
7
8use crate::backend::storage::store::Store;
9use crate::backend::storage::StoreError;
10use crate::backend::obj::*;
11
12use super::tab::{create_tab, delete_tab, set_active_tab};
13
14/// Move a block from one tab to another.
15/// Removes the block from `source_tab_id.blockids` and adds it to `dest_tab_id.blockids`.
16/// Updates `block.parentoref` to point to the destination tab.
17/// If `auto_close_source` is true, deletes the source tab when it becomes empty
18/// (only if the workspace has other tabs).
19pub fn move_block_to_tab(
20    store: &Store,
21    block_id: &str,
22    source_tab_id: &str,
23    dest_tab_id: &str,
24    ws_id: &str,
25    auto_close_source: bool,
26) -> Result<(), StoreError> {
27    tracing::info!(
28        block_id = %block_id,
29        source_tab = %source_tab_id,
30        dest_tab = %dest_tab_id,
31        ws_id = %ws_id,
32        auto_close = %auto_close_source,
33        "[dnd] move_block_to_tab"
34    );
35    if source_tab_id == dest_tab_id {
36        tracing::debug!("[dnd] move_block_to_tab: same tab, no-op");
37        return Ok(()); // no-op
38    }
39
40    // Verify block exists
41    let mut block = store.must_get::<Block>(block_id)?;
42
43    // Remove block from source tab
44    let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
45    source_tab.blockids.retain(|id| id != block_id);
46    store.update(&mut source_tab)?;
47
48    // Add block to destination tab
49    let mut dest_tab = store.must_get::<Tab>(dest_tab_id)?;
50    dest_tab.blockids.push(block_id.to_string());
51    store.update(&mut dest_tab)?;
52
53    // Update block's parent reference
54    block.parentoref = format!("tab:{}", dest_tab_id);
55    store.update(&mut block)?;
56
57    // Auto-close empty source tab if requested
58    if auto_close_source && source_tab.blockids.is_empty() {
59        let ws = store.must_get::<Workspace>(ws_id)?;
60        let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
61        if total_tabs > 1 {
62            tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab");
63            delete_tab(store, ws_id, source_tab_id)?;
64        } else {
65            tracing::debug!(source_tab = %source_tab_id, "[dnd] source tab empty but is last tab — keeping");
66        }
67    }
68
69    tracing::info!(block_id = %block_id, dest_tab = %dest_tab_id, "[dnd] move_block_to_tab complete");
70    Ok(())
71}
72
73/// Promote a block to a new tab.
74/// Removes the block from `source_tab_id`, creates a new tab in `ws_id`,
75/// and adds the block to the new tab. Returns the new Tab.
76/// If `auto_close_source` is true, deletes the source tab when it becomes empty.
77pub fn promote_block_to_tab(
78    store: &Store,
79    block_id: &str,
80    source_tab_id: &str,
81    ws_id: &str,
82    auto_close_source: bool,
83) -> Result<Tab, StoreError> {
84    tracing::info!(
85        block_id = %block_id,
86        source_tab = %source_tab_id,
87        ws_id = %ws_id,
88        auto_close = %auto_close_source,
89        "[dnd] promote_block_to_tab"
90    );
91    // Verify block exists
92    let mut block = store.must_get::<Block>(block_id)?;
93
94    // Remove block from source tab
95    let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
96    source_tab.blockids.retain(|id| id != block_id);
97    store.update(&mut source_tab)?;
98
99    // Create new tab
100    let new_tab = create_tab(store, ws_id)?;
101
102    // Add block to new tab
103    let mut new_tab = store.must_get::<Tab>(&new_tab.oid)?;
104    new_tab.blockids.push(block_id.to_string());
105    store.update(&mut new_tab)?;
106
107    // Update block's parent reference
108    block.parentoref = format!("tab:{}", new_tab.oid);
109    store.update(&mut block)?;
110
111    // Set the new tab as active
112    set_active_tab(store, ws_id, &new_tab.oid)?;
113
114    // Auto-close empty source tab if requested
115    if auto_close_source && source_tab.blockids.is_empty() {
116        let ws = store.must_get::<Workspace>(ws_id)?;
117        let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
118        if total_tabs > 1 {
119            tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab after promote");
120            delete_tab(store, ws_id, source_tab_id)?;
121        }
122    }
123
124    tracing::info!(block_id = %block_id, new_tab = %new_tab.oid, "[dnd] promote_block_to_tab complete");
125    Ok(new_tab)
126}
127
128/// Move a tab from one workspace to another.
129/// Removes the tab from the source workspace's tabids/pinnedtabids and adds it
130/// to the destination workspace's tabids at the specified index.
131/// The tab is always added as unpinned in the destination.
132/// If the tab was the active tab in the source workspace, a new active tab is chosen.
133pub fn move_tab_to_workspace(
134    store: &Store,
135    tab_id: &str,
136    source_ws_id: &str,
137    dest_ws_id: &str,
138    insert_index: Option<usize>,
139) -> Result<(), StoreError> {
140    tracing::info!(
141        tab_id = %tab_id,
142        source_ws = %source_ws_id,
143        dest_ws = %dest_ws_id,
144        insert_index = ?insert_index,
145        "[dnd] move_tab_to_workspace"
146    );
147    if source_ws_id == dest_ws_id {
148        tracing::debug!("[dnd] move_tab_to_workspace: same workspace, no-op");
149        return Ok(()); // no-op
150    }
151
152    // Verify tab exists
153    let _tab = store.must_get::<Tab>(tab_id)?;
154
155    // Remove tab from source workspace
156    let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
157    let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
158    if total_tabs <= 1 {
159        tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] move_tab_to_workspace blocked: last tab");
160        return Err(StoreError::Other(
161            "cannot move last tab out of workspace".to_string(),
162        ));
163    }
164    source_ws.tabids.retain(|id| id != tab_id);
165    source_ws.pinnedtabids.retain(|id| id != tab_id);
166    if source_ws.activetabid == tab_id {
167        let new_active = source_ws
168            .tabids
169            .first()
170            .or(source_ws.pinnedtabids.first())
171            .cloned()
172            .unwrap_or_default();
173        tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab in source workspace");
174        source_ws.activetabid = new_active;
175    }
176    store.update(&mut source_ws)?;
177
178    // Add tab to destination workspace
179    let mut dest_ws = store.must_get::<Workspace>(dest_ws_id)?;
180    let idx = insert_index.unwrap_or(dest_ws.tabids.len());
181    let insert_at = idx.min(dest_ws.tabids.len());
182    dest_ws.tabids.insert(insert_at, tab_id.to_string());
183    dest_ws.activetabid = tab_id.to_string();
184    store.update(&mut dest_ws)?;
185
186    tracing::info!(tab_id = %tab_id, dest_ws = %dest_ws_id, insert_at = %insert_at, "[dnd] move_tab_to_workspace complete");
187    Ok(())
188}
189
190/// Move a tab back from a tear-off workspace into a destination workspace.
191/// Unlike `move_tab_to_workspace`, this skips the "last tab" guard because
192/// tear-off workspaces always contain exactly one tab — and after the move
193/// the source workspace has zero tabs and is deleted.
194///
195/// `was_pinned` controls which list in the destination receives the tab:
196/// true → `pinnedtabids` (preserves pinned status across cancel-back),
197/// false → `tabids`. Both paths de-dup before insert.
198///
199/// The whole operation runs inside a single SQLite transaction so frontend
200/// readers never observe a transient state where the tab lives in two
201/// workspaces' lists at once. (gemini PR #567 round-6 MEDIUM)
202///
203/// Used by Phase 5 cancel-back (ESC / drop-on-source) and by Phase 4 merge
204/// (drop on another window's strip). Both paths produce a single-tab source
205/// workspace whose only purpose is to carry the dragged tab.
206pub fn restore_torn_off_tab(
207    store: &Store,
208    tab_id: &str,
209    source_ws_id: &str,
210    dest_ws_id: &str,
211    insert_index: Option<usize>,
212    was_pinned: bool,
213) -> Result<(), StoreError> {
214    tracing::info!(
215        tab_id = %tab_id,
216        source_ws = %source_ws_id,
217        dest_ws = %dest_ws_id,
218        insert_index = ?insert_index,
219        was_pinned = %was_pinned,
220        "[dnd] restore_torn_off_tab"
221    );
222    if source_ws_id == dest_ws_id {
223        tracing::debug!("[dnd] restore_torn_off_tab: same workspace, no-op");
224        return Ok(());
225    }
226
227    store.with_tx(|tx| {
228        // Validate everything we're going to touch BEFORE mutating any of
229        // it. If `dest_ws_id` is stale (e.g. user closed the original
230        // window while the SC_MOVE loop was running, or sent a malformed
231        // request), `must_get::<Workspace>(dest_ws_id)` fails here and
232        // the transaction rolls back without ever touching the source —
233        // no orphaned tab. (codex PR #567 P1 / gemini HIGH)
234        let _tab = tx.must_get::<Tab>(tab_id)?;
235        let mut source_ws = tx.must_get::<Workspace>(source_ws_id)?;
236        let mut dest_ws = tx.must_get::<Workspace>(dest_ws_id)?;
237
238        // Compute source-side mutations in memory only (don't persist yet).
239        // No last-tab guard: a tear-off workspace's only tab IS the one
240        // being restored, and we'll delete the empty workspace below.
241        source_ws.tabids.retain(|id| id != tab_id);
242        source_ws.pinnedtabids.retain(|id| id != tab_id);
243        let source_now_empty =
244            source_ws.tabids.is_empty() && source_ws.pinnedtabids.is_empty();
245        if source_ws.activetabid == tab_id {
246            // Only assign a new active tab if one actually exists.
247            // When source_now_empty, both lists are empty and we go
248            // straight to delete below — no need to write an empty
249            // activetabid into the persisted record. (gemini PR #567
250            // round-8 MEDIUM)
251            if let Some(new_active) = source_ws
252                .tabids
253                .first()
254                .or(source_ws.pinnedtabids.first())
255                .cloned()
256            {
257                source_ws.activetabid = new_active;
258            }
259        }
260
261        // De-dup in destination before insert. A retried frontend request
262        // could otherwise produce duplicate tab IDs in the workspace's
263        // `tabids` array. (gemini PR #567 MEDIUM)
264        dest_ws.tabids.retain(|id| id != tab_id);
265        dest_ws.pinnedtabids.retain(|id| id != tab_id);
266
267        // Choose pinnedtabids vs tabids based on the tab's original
268        // status. Without this, a pinned tab torn off and cancel-backed
269        // would silently come back as unpinned. (gemini PR #567 round-6
270        // MEDIUM on this branch.) Pinning has since left the UI — tabbar.tsx
271        // folds legacy `pinnedtabids` into `tabids` on mount — so this only
272        // matters for a workspace whose pins have not been migrated yet.
273        let insert_at = if was_pinned {
274            let idx = insert_index.unwrap_or(dest_ws.pinnedtabids.len());
275            let insert_at = idx.min(dest_ws.pinnedtabids.len());
276            dest_ws.pinnedtabids.insert(insert_at, tab_id.to_string());
277            insert_at
278        } else {
279            let idx = insert_index.unwrap_or(dest_ws.tabids.len());
280            let insert_at = idx.min(dest_ws.tabids.len());
281            dest_ws.tabids.insert(insert_at, tab_id.to_string());
282            insert_at
283        };
284        dest_ws.activetabid = tab_id.to_string();
285
286        // Inside the transaction, persistence order doesn't matter for
287        // observers (they only see post-COMMIT state). Persist dest
288        // first anyway to keep the function flow obvious if the txn
289        // wrapper is ever removed. Skip source update entirely when
290        // it's becoming empty — go straight to delete.
291        tx.update(&mut dest_ws)?;
292        if source_now_empty {
293            tracing::info!(source_ws = %source_ws_id, "[dnd] deleting empty tear-off workspace");
294            tx.delete::<Workspace>(source_ws_id)?;
295        } else {
296            tx.update(&mut source_ws)?;
297        }
298
299        tracing::info!(
300            tab_id = %tab_id,
301            dest_ws = %dest_ws_id,
302            insert_at = %insert_at,
303            was_pinned = %was_pinned,
304            "[dnd] restore_torn_off_tab complete"
305        );
306        Ok(())
307    })
308}
309
310/// Tear off a tab into a new workspace.
311/// Removes the tab from the source workspace and creates a new workspace
312/// containing just that tab. Returns the new workspace.
313/// The source workspace must have more than one tab.
314pub fn tear_off_tab(
315    store: &Store,
316    tab_id: &str,
317    source_ws_id: &str,
318) -> Result<Workspace, StoreError> {
319    tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd] tear_off_tab");
320    // Remove tab from source workspace
321    let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
322    let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
323    if total_tabs <= 1 {
324        tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] tear_off_tab blocked: last tab");
325        return Err(StoreError::Other(
326            "cannot tear off last tab from workspace".to_string(),
327        ));
328    }
329    source_ws.tabids.retain(|id| id != tab_id);
330    source_ws.pinnedtabids.retain(|id| id != tab_id);
331    if source_ws.activetabid == tab_id {
332        let new_active = source_ws
333            .tabids
334            .first()
335            .or(source_ws.pinnedtabids.first())
336            .cloned()
337            .unwrap_or_default();
338        tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab after tear-off");
339        source_ws.activetabid = new_active;
340    }
341    store.update(&mut source_ws)?;
342
343    // Create a new workspace with the tab
344    let mut new_ws = Workspace {
345        oid: Uuid::new_v4().to_string(),
346        name: String::new(),
347        tabids: vec![tab_id.to_string()],
348        pinnedtabids: vec![],
349        activetabid: tab_id.to_string(),
350        meta: MetaMapType::new(),
351        ..Default::default()
352    };
353    store.insert(&mut new_ws)?;
354
355    tracing::info!(tab_id = %tab_id, new_ws = %new_ws.oid, "[dnd] tear_off_tab complete");
356    Ok(new_ws)
357}