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::wstore::WaveStore;
9use crate::backend::storage::StoreError;
10use crate::backend::obj::*;
11
12use super::tab::{create_tab, delete_tab, set_active_tab};
13use super::workspace::create_workspace;
14
15/// Move a block from one tab to another.
16/// Removes the block from `source_tab_id.blockids` and adds it to `dest_tab_id.blockids`.
17/// Updates `block.parentoref` to point to the destination tab.
18/// If `auto_close_source` is true, deletes the source tab when it becomes empty
19/// (only if the workspace has other tabs).
20pub fn move_block_to_tab(
21    store: &WaveStore,
22    block_id: &str,
23    source_tab_id: &str,
24    dest_tab_id: &str,
25    ws_id: &str,
26    auto_close_source: bool,
27) -> Result<(), StoreError> {
28    tracing::info!(
29        block_id = %block_id,
30        source_tab = %source_tab_id,
31        dest_tab = %dest_tab_id,
32        ws_id = %ws_id,
33        auto_close = %auto_close_source,
34        "[dnd] move_block_to_tab"
35    );
36    if source_tab_id == dest_tab_id {
37        tracing::debug!("[dnd] move_block_to_tab: same tab, no-op");
38        return Ok(()); // no-op
39    }
40
41    // Verify block exists
42    let mut block = store.must_get::<Block>(block_id)?;
43
44    // Remove block from source tab
45    let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
46    source_tab.blockids.retain(|id| id != block_id);
47    store.update(&mut source_tab)?;
48
49    // Add block to destination tab
50    let mut dest_tab = store.must_get::<Tab>(dest_tab_id)?;
51    dest_tab.blockids.push(block_id.to_string());
52    store.update(&mut dest_tab)?;
53
54    // Update block's parent reference
55    block.parentoref = format!("tab:{}", dest_tab_id);
56    store.update(&mut block)?;
57
58    // Auto-close empty source tab if requested
59    if auto_close_source && source_tab.blockids.is_empty() {
60        let ws = store.must_get::<Workspace>(ws_id)?;
61        let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
62        if total_tabs > 1 {
63            tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab");
64            delete_tab(store, ws_id, source_tab_id)?;
65        } else {
66            tracing::debug!(source_tab = %source_tab_id, "[dnd] source tab empty but is last tab — keeping");
67        }
68    }
69
70    tracing::info!(block_id = %block_id, dest_tab = %dest_tab_id, "[dnd] move_block_to_tab complete");
71    Ok(())
72}
73
74/// Promote a block to a new tab.
75/// Removes the block from `source_tab_id`, creates a new tab in `ws_id`,
76/// and adds the block to the new tab. Returns the new Tab.
77/// If `auto_close_source` is true, deletes the source tab when it becomes empty.
78pub fn promote_block_to_tab(
79    store: &WaveStore,
80    block_id: &str,
81    source_tab_id: &str,
82    ws_id: &str,
83    auto_close_source: bool,
84) -> Result<Tab, StoreError> {
85    tracing::info!(
86        block_id = %block_id,
87        source_tab = %source_tab_id,
88        ws_id = %ws_id,
89        auto_close = %auto_close_source,
90        "[dnd] promote_block_to_tab"
91    );
92    // Verify block exists
93    let mut block = store.must_get::<Block>(block_id)?;
94
95    // Remove block from source tab
96    let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
97    source_tab.blockids.retain(|id| id != block_id);
98    store.update(&mut source_tab)?;
99
100    // Create new tab
101    let new_tab = create_tab(store, ws_id)?;
102
103    // Add block to new tab
104    let mut new_tab = store.must_get::<Tab>(&new_tab.oid)?;
105    new_tab.blockids.push(block_id.to_string());
106    store.update(&mut new_tab)?;
107
108    // Update block's parent reference
109    block.parentoref = format!("tab:{}", new_tab.oid);
110    store.update(&mut block)?;
111
112    // Set the new tab as active
113    set_active_tab(store, ws_id, &new_tab.oid)?;
114
115    // Auto-close empty source tab if requested
116    if auto_close_source && source_tab.blockids.is_empty() {
117        let ws = store.must_get::<Workspace>(ws_id)?;
118        let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
119        if total_tabs > 1 {
120            tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab after promote");
121            delete_tab(store, ws_id, source_tab_id)?;
122        }
123    }
124
125    tracing::info!(block_id = %block_id, new_tab = %new_tab.oid, "[dnd] promote_block_to_tab complete");
126    Ok(new_tab)
127}
128
129/// Move a tab from one workspace to another.
130/// Removes the tab from the source workspace's tabids/pinnedtabids and adds it
131/// to the destination workspace's tabids at the specified index.
132/// The tab is always added as unpinned in the destination.
133/// If the tab was the active tab in the source workspace, a new active tab is chosen.
134pub fn move_tab_to_workspace(
135    store: &WaveStore,
136    tab_id: &str,
137    source_ws_id: &str,
138    dest_ws_id: &str,
139    insert_index: Option<usize>,
140) -> Result<(), StoreError> {
141    tracing::info!(
142        tab_id = %tab_id,
143        source_ws = %source_ws_id,
144        dest_ws = %dest_ws_id,
145        insert_index = ?insert_index,
146        "[dnd] move_tab_to_workspace"
147    );
148    if source_ws_id == dest_ws_id {
149        tracing::debug!("[dnd] move_tab_to_workspace: same workspace, no-op");
150        return Ok(()); // no-op
151    }
152
153    // Verify tab exists
154    let _tab = store.must_get::<Tab>(tab_id)?;
155
156    // Remove tab from source workspace
157    let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
158    let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
159    if total_tabs <= 1 {
160        tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] move_tab_to_workspace blocked: last tab");
161        return Err(StoreError::Other(
162            "cannot move last tab out of workspace".to_string(),
163        ));
164    }
165    source_ws.tabids.retain(|id| id != tab_id);
166    source_ws.pinnedtabids.retain(|id| id != tab_id);
167    if source_ws.activetabid == tab_id {
168        let new_active = source_ws
169            .tabids
170            .first()
171            .or(source_ws.pinnedtabids.first())
172            .cloned()
173            .unwrap_or_default();
174        tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab in source workspace");
175        source_ws.activetabid = new_active;
176    }
177    store.update(&mut source_ws)?;
178
179    // Add tab to destination workspace
180    let mut dest_ws = store.must_get::<Workspace>(dest_ws_id)?;
181    let idx = insert_index.unwrap_or(dest_ws.tabids.len());
182    let insert_at = idx.min(dest_ws.tabids.len());
183    dest_ws.tabids.insert(insert_at, tab_id.to_string());
184    dest_ws.activetabid = tab_id.to_string();
185    store.update(&mut dest_ws)?;
186
187    tracing::info!(tab_id = %tab_id, dest_ws = %dest_ws_id, insert_at = %insert_at, "[dnd] move_tab_to_workspace complete");
188    Ok(())
189}
190
191/// Tear off a block into a new workspace.
192/// Removes the block from `source_tab_id`, creates a new workspace with a
193/// single tab containing the block. Returns the new workspace.
194/// If `auto_close_source` is true, deletes the source tab when it becomes empty.
195pub fn tear_off_block(
196    store: &WaveStore,
197    block_id: &str,
198    source_tab_id: &str,
199    source_ws_id: &str,
200    auto_close_source: bool,
201) -> Result<Workspace, StoreError> {
202    tracing::info!(
203        block_id = %block_id,
204        source_tab = %source_tab_id,
205        source_ws = %source_ws_id,
206        auto_close = %auto_close_source,
207        "[dnd] tear_off_block"
208    );
209    // Verify block exists
210    let mut block = store.must_get::<Block>(block_id)?;
211
212    // Remove block from source tab's blockids and queue a layout delete action
213    // so the source window's frontend removes the node from its layout tree.
214    let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
215    source_tab.blockids.retain(|id| id != block_id);
216    store.update(&mut source_tab)?;
217
218    let mut source_layout = store.must_get::<LayoutState>(&source_tab.layoutstate)?;
219    let mut actions = source_layout.pendingbackendactions.take().unwrap_or_default();
220    actions.push(LayoutActionData {
221        actiontype: "delete".to_string(),
222        actionid: Uuid::new_v4().to_string(),
223        blockid: block_id.to_string(),
224        nodesize: None,
225        indexarr: None,
226        focused: false,
227        magnified: false,
228        ephemeral: false,
229        targetblockid: String::new(),
230        position: String::new(),
231    });
232    source_layout.pendingbackendactions = Some(actions);
233    store.update(&mut source_layout)?;
234
235    // Create new workspace
236    let new_ws = create_workspace(store, "")?;
237    // create_tab adds a tab and sets it as active
238    let new_tab = create_tab(store, &new_ws.oid)?;
239
240    // Add block to the new tab
241    let mut new_tab = store.must_get::<Tab>(&new_tab.oid)?;
242    new_tab.blockids.push(block_id.to_string());
243    store.update(&mut new_tab)?;
244
245    // Set up the layout tree for the new tab with the block as the single root node.
246    // Without this, the frontend renders an empty layout (rootnode: null).
247    // Phase E.4.B Phase 2 — typed LayoutNode (was inline JSON).
248    let mut layout = store.must_get::<LayoutState>(&new_tab.layoutstate)?;
249    let node_id = Uuid::new_v4().to_string();
250    layout.rootnode = Some(LayoutNode {
251        id: node_id.clone(),
252        flex_direction: FlexDirection::Row,
253        size: 1.0,
254        children: Vec::new(),
255        data: Some(LayoutNodeData {
256            block_id: block_id.to_string(),
257            ..Default::default()
258        }),
259        ..Default::default()
260    });
261    layout.leaforder = Some(vec![LeafOrderEntry {
262        nodeid: node_id,
263        blockid: block_id.to_string(),
264    }]);
265    store.update(&mut layout)?;
266
267    // Update block's parent reference
268    block.parentoref = format!("tab:{}", new_tab.oid);
269    store.update(&mut block)?;
270
271    // Auto-close empty source tab if requested
272    if auto_close_source && source_tab.blockids.is_empty() {
273        let ws = store.must_get::<Workspace>(source_ws_id)?;
274        let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
275        if total_tabs > 1 {
276            tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab after tear-off");
277            delete_tab(store, source_ws_id, source_tab_id)?;
278        }
279    }
280
281    tracing::info!(
282        block_id = %block_id,
283        new_ws = %new_ws.oid,
284        new_tab = %new_tab.oid,
285        "[dnd] tear_off_block complete"
286    );
287    // Re-fetch workspace to return updated state
288    store.must_get::<Workspace>(&new_ws.oid)
289}
290
291/// Move a tab back from a tear-off workspace into a destination workspace.
292/// Unlike `move_tab_to_workspace`, this skips the "last tab" guard because
293/// tear-off workspaces always contain exactly one tab — and after the move
294/// the source workspace has zero tabs and is deleted.
295///
296/// `was_pinned` controls which list in the destination receives the tab:
297/// true → `pinnedtabids` (preserves pinned status across cancel-back),
298/// false → `tabids`. Both paths de-dup before insert.
299///
300/// The whole operation runs inside a single SQLite transaction so frontend
301/// readers never observe a transient state where the tab lives in two
302/// workspaces' lists at once. (gemini PR #567 round-6 MEDIUM)
303///
304/// Used by Phase 5 cancel-back (ESC / drop-on-source) and by Phase 4 merge
305/// (drop on another window's strip). Both paths produce a single-tab source
306/// workspace whose only purpose is to carry the dragged tab.
307pub fn restore_torn_off_tab(
308    store: &WaveStore,
309    tab_id: &str,
310    source_ws_id: &str,
311    dest_ws_id: &str,
312    insert_index: Option<usize>,
313    was_pinned: bool,
314) -> Result<(), StoreError> {
315    tracing::info!(
316        tab_id = %tab_id,
317        source_ws = %source_ws_id,
318        dest_ws = %dest_ws_id,
319        insert_index = ?insert_index,
320        was_pinned = %was_pinned,
321        "[dnd] restore_torn_off_tab"
322    );
323    if source_ws_id == dest_ws_id {
324        tracing::debug!("[dnd] restore_torn_off_tab: same workspace, no-op");
325        return Ok(());
326    }
327
328    store.with_tx(|tx| {
329        // Validate everything we're going to touch BEFORE mutating any of
330        // it. If `dest_ws_id` is stale (e.g. user closed the original
331        // window while the SC_MOVE loop was running, or sent a malformed
332        // request), `must_get::<Workspace>(dest_ws_id)` fails here and
333        // the transaction rolls back without ever touching the source —
334        // no orphaned tab. (codex PR #567 P1 / gemini HIGH)
335        let _tab = tx.must_get::<Tab>(tab_id)?;
336        let mut source_ws = tx.must_get::<Workspace>(source_ws_id)?;
337        let mut dest_ws = tx.must_get::<Workspace>(dest_ws_id)?;
338
339        // Compute source-side mutations in memory only (don't persist yet).
340        // No last-tab guard: a tear-off workspace's only tab IS the one
341        // being restored, and we'll delete the empty workspace below.
342        source_ws.tabids.retain(|id| id != tab_id);
343        source_ws.pinnedtabids.retain(|id| id != tab_id);
344        let source_now_empty =
345            source_ws.tabids.is_empty() && source_ws.pinnedtabids.is_empty();
346        if source_ws.activetabid == tab_id {
347            // Only assign a new active tab if one actually exists.
348            // When source_now_empty, both lists are empty and we go
349            // straight to delete below — no need to write an empty
350            // activetabid into the persisted record. (gemini PR #567
351            // round-8 MEDIUM)
352            if let Some(new_active) = source_ws
353                .tabids
354                .first()
355                .or(source_ws.pinnedtabids.first())
356                .cloned()
357            {
358                source_ws.activetabid = new_active;
359            }
360        }
361
362        // De-dup in destination before insert. A retried frontend request
363        // could otherwise produce duplicate tab IDs in the workspace's
364        // `tabids` array. (gemini PR #567 MEDIUM)
365        dest_ws.tabids.retain(|id| id != tab_id);
366        dest_ws.pinnedtabids.retain(|id| id != tab_id);
367
368        // Choose pinnedtabids vs tabids based on the tab's original
369        // status. Without this, a pinned tab torn off and cancel-backed
370        // would silently come back as unpinned. (gemini PR #567 round-6
371        // MEDIUM @ tabbar.tsx:154 + dnd.rs:345)
372        let insert_at = if was_pinned {
373            let idx = insert_index.unwrap_or(dest_ws.pinnedtabids.len());
374            let insert_at = idx.min(dest_ws.pinnedtabids.len());
375            dest_ws.pinnedtabids.insert(insert_at, tab_id.to_string());
376            insert_at
377        } else {
378            let idx = insert_index.unwrap_or(dest_ws.tabids.len());
379            let insert_at = idx.min(dest_ws.tabids.len());
380            dest_ws.tabids.insert(insert_at, tab_id.to_string());
381            insert_at
382        };
383        dest_ws.activetabid = tab_id.to_string();
384
385        // Inside the transaction, persistence order doesn't matter for
386        // observers (they only see post-COMMIT state). Persist dest
387        // first anyway to keep the function flow obvious if the txn
388        // wrapper is ever removed. Skip source update entirely when
389        // it's becoming empty — go straight to delete.
390        tx.update(&mut dest_ws)?;
391        if source_now_empty {
392            tracing::info!(source_ws = %source_ws_id, "[dnd] deleting empty tear-off workspace");
393            tx.delete::<Workspace>(source_ws_id)?;
394        } else {
395            tx.update(&mut source_ws)?;
396        }
397
398        tracing::info!(
399            tab_id = %tab_id,
400            dest_ws = %dest_ws_id,
401            insert_at = %insert_at,
402            was_pinned = %was_pinned,
403            "[dnd] restore_torn_off_tab complete"
404        );
405        Ok(())
406    })
407}
408
409/// Tear off a tab into a new workspace.
410/// Removes the tab from the source workspace and creates a new workspace
411/// containing just that tab. Returns the new workspace.
412/// The source workspace must have more than one tab.
413pub fn tear_off_tab(
414    store: &WaveStore,
415    tab_id: &str,
416    source_ws_id: &str,
417) -> Result<Workspace, StoreError> {
418    tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd] tear_off_tab");
419    // Remove tab from source workspace
420    let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
421    let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
422    if total_tabs <= 1 {
423        tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] tear_off_tab blocked: last tab");
424        return Err(StoreError::Other(
425            "cannot tear off last tab from workspace".to_string(),
426        ));
427    }
428    source_ws.tabids.retain(|id| id != tab_id);
429    source_ws.pinnedtabids.retain(|id| id != tab_id);
430    if source_ws.activetabid == tab_id {
431        let new_active = source_ws
432            .tabids
433            .first()
434            .or(source_ws.pinnedtabids.first())
435            .cloned()
436            .unwrap_or_default();
437        tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab after tear-off");
438        source_ws.activetabid = new_active;
439    }
440    store.update(&mut source_ws)?;
441
442    // Create a new workspace with the tab
443    let mut new_ws = Workspace {
444        oid: Uuid::new_v4().to_string(),
445        name: String::new(),
446        tabids: vec![tab_id.to_string()],
447        pinnedtabids: vec![],
448        activetabid: tab_id.to_string(),
449        meta: MetaMapType::new(),
450        ..Default::default()
451    };
452    store.insert(&mut new_ws)?;
453
454    tracing::info!(tab_id = %tab_id, new_ws = %new_ws.oid, "[dnd] tear_off_tab complete");
455    Ok(new_ws)
456}