1use crate::backend::obj::*;
9use crate::backend::service::{self, WebCallType, WebReturnType};
10
11use super::super::AppState;
12use super::layout_helpers::{
13 queue_source_layout_delete, queue_target_layout_insert, queue_target_layout_split,
14 queue_target_stack_push, setup_torn_off_block_layout,
15};
16use super::reducer_helpers::{dispatch_to_reducer, publish_events};
17
18pub(crate) async fn handle_tear_off_block(state: &AppState, call: &WebCallType) -> WebReturnType {
30 let store = &state.mstore;
31 let args = &call.args;
32 let block_id: String = match service::get_arg(args, 0) {
33 Ok(v) => v,
34 Err(e) => return WebReturnType::error(e),
35 };
36 let source_tab_id: String = match service::get_arg(args, 1) {
37 Ok(v) => v,
38 Err(e) => return WebReturnType::error(e),
39 };
40 let source_ws_id: String = match service::get_arg(args, 2) {
41 Ok(v) => v,
42 Err(e) => return WebReturnType::error(e),
43 };
44 let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
45 tracing::info!(block_id = %block_id, source_tab = %source_tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffBlock via saga");
46 let saga_result = crate::sagas::tear_off_block::run(
47 state,
48 block_id.clone(),
49 source_tab_id.clone(),
50 source_ws_id.clone(),
51 )
52 .await;
53 let (new_ws_oid, new_tab_oid) = match saga_result {
54 Ok(value) => {
55 let new_ws_oid = value
56 .get("new_workspace_id")
57 .and_then(|v| v.as_str())
58 .unwrap_or_default()
59 .to_string();
60 let new_tab_oid = value
61 .get("new_tab_id")
62 .and_then(|v| v.as_str())
63 .unwrap_or_default()
64 .to_string();
65 (new_ws_oid, new_tab_oid)
66 }
67 Err(reason) => return WebReturnType::error(reason),
68 };
69
70 if let Err(e) = setup_torn_off_block_layout(state, &new_tab_oid, &block_id).await {
74 tracing::warn!(new_tab = %new_tab_oid, "TearOffBlock: layout setup failed: {} (block in tab but layout malformed)", e);
75 }
76 let prune_events = dispatch_to_reducer(
86 state,
87 agentmux_common::ipc::Command::LayoutDeleteNodeByBlock {
88 tab_id: source_tab_id.clone(),
89 block_id: block_id.clone(),
90 correlation_id: String::new(),
91 },
92 )
93 .await;
94 if let Some(msg) = prune_events.iter().find_map(|e| match e {
100 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
101 _ => None,
102 }) {
103 tracing::warn!(
104 source_tab = %source_tab_id,
105 block_id = %block_id,
106 "TearOffBlock: backend source-layout prune failed (best-effort; the source window's queued delete still applies): {}",
107 msg
108 );
109 }
110 for ev in &prune_events {
111 let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
112 }
113 publish_events(state, &prune_events);
114
115 if let Err(e) = queue_source_layout_delete(state, &source_tab_id, &block_id).await {
118 tracing::warn!(source_tab = %source_tab_id, "TearOffBlock: source layout delete-action enqueue failed: {}", e);
119 }
120
121 if auto_close {
126 let should_close = match store.must_get::<Tab>(&source_tab_id) {
127 Ok(t) => t.blockids.is_empty(),
128 Err(_) => false,
129 };
130 if should_close {
131 let total_tabs = match store.must_get::<Workspace>(&source_ws_id) {
132 Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
133 Err(_) => 0,
134 };
135 if total_tabs > 1 {
136 tracing::info!(source_tab = %source_tab_id, "[dnd:svc] auto-closing empty source tab after TearOffBlock");
137 let close_events = dispatch_to_reducer(
138 state,
139 agentmux_common::ipc::Command::DeleteTab {
140 workspace_id: source_ws_id.clone(),
141 tab_id: source_tab_id.clone(),
142 force: false,
145 },
146 )
147 .await;
148 for ev in &close_events {
149 let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
150 }
151 publish_events(state, &close_events);
152 }
153 }
154 }
155
156 let mut updates = Vec::new();
157 if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
158 updates.push(MuxObjUpdate {
159 updatetype: "update".into(),
160 otype: OTYPE_TAB.to_string(),
161 oid: source_tab_id.clone(),
162 obj: Some(mux_obj_to_value(&src_tab)),
163 });
164 }
165 if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
166 updates.push(MuxObjUpdate {
167 updatetype: "update".into(),
168 otype: OTYPE_WORKSPACE.to_string(),
169 oid: source_ws_id.clone(),
170 obj: Some(mux_obj_to_value(&src_ws)),
171 });
172 }
173 if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
174 updates.push(MuxObjUpdate {
175 updatetype: "update".into(),
176 otype: OTYPE_WORKSPACE.to_string(),
177 oid: new_ws_oid.clone(),
178 obj: Some(mux_obj_to_value(&new_ws)),
179 });
180 }
181 WebReturnType::success_data_updates(
182 serde_json::to_value(&new_ws_oid).unwrap_or_default(),
183 updates,
184 )
185}
186
187pub(crate) async fn handle_redock_floating_pane(state: &AppState, call: &WebCallType) -> WebReturnType {
196 let store = &state.mstore;
197 let args = &call.args;
198 let block_id: String = match service::get_arg(args, 0) {
199 Ok(v) => v,
200 Err(e) => return WebReturnType::error(e),
201 };
202 let source_tab_id: String = match service::get_arg(args, 1) {
203 Ok(v) => v,
204 Err(e) => return WebReturnType::error(e),
205 };
206 let source_ws_id: String = match service::get_arg(args, 2) {
207 Ok(v) => v,
208 Err(e) => return WebReturnType::error(e),
209 };
210 let target_tab_id: String = match service::get_arg(args, 3) {
211 Ok(v) => v,
212 Err(e) => return WebReturnType::error(e),
213 };
214 let target_ws_id: String = match service::get_arg(args, 4) {
215 Ok(v) => v,
216 Err(e) => return WebReturnType::error(e),
217 };
218 let target_block_id: Option<String> = service::get_optional_arg(args, 5)
221 .unwrap_or(None);
222 let direction: Option<u8> = service::get_optional_arg(args, 6)
223 .unwrap_or(None);
224 let as_tab: bool = service::get_optional_arg(args, 7).unwrap_or(None).unwrap_or(false);
229 tracing::info!(
230 block_id = %block_id,
231 source_tab = %source_tab_id,
232 source_ws = %source_ws_id,
233 target_tab = %target_tab_id,
234 target_ws = %target_ws_id,
235 target_block_id = ?target_block_id,
236 direction = ?direction,
237 "[dnd:svc] RedockFloatingPane via saga"
238 );
239 let saga_result = crate::sagas::redock_floating_pane::run(
240 state,
241 block_id.clone(),
242 source_tab_id.clone(),
243 source_ws_id.clone(),
244 target_tab_id.clone(),
245 target_ws_id.clone(),
246 None,
247 )
248 .await;
249 if let Err(reason) = saga_result {
250 return WebReturnType::error(reason);
251 }
252
253 let target_layout_result = match (target_block_id.as_deref(), direction) {
262 (Some(tbid), _) if as_tab => redock_as_tab(state, &target_tab_id, &block_id, tbid).await,
263 (Some(tbid), Some(dir)) => {
264 queue_target_layout_split(state, &target_tab_id, &block_id, tbid, dir).await
265 }
266 _ => queue_target_layout_insert(state, &target_tab_id, &block_id).await,
267 };
268 if let Err(e) = target_layout_result {
269 tracing::error!(
270 target_tab = %target_tab_id,
271 "RedockFloatingPane: target layout action failed — aborting broadcast: {}",
272 e
273 );
274 return WebReturnType::error(format!(
275 "redock layout action failed: {e}"
276 ));
277 }
278 if let Err(e) = queue_source_layout_delete(state, &source_tab_id, &block_id).await {
279 tracing::error!(
280 source_tab = %source_tab_id,
281 "RedockFloatingPane: source layout delete failed — aborting broadcast: {}",
282 e
283 );
284 return WebReturnType::error(format!(
285 "redock layout delete failed: {e}"
286 ));
287 }
288
289 let mut updates = Vec::new();
290 if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
300 if let Ok(src_layout) = store.must_get::<LayoutState>(&src_tab.layoutstate) {
301 updates.push(MuxObjUpdate {
302 updatetype: "update".into(),
303 otype: OTYPE_LAYOUT.to_string(),
304 oid: src_tab.layoutstate.clone(),
305 obj: Some(mux_obj_to_value(&src_layout)),
306 });
307 }
308 updates.push(MuxObjUpdate {
309 updatetype: "update".into(),
310 otype: OTYPE_TAB.to_string(),
311 oid: source_tab_id.clone(),
312 obj: Some(mux_obj_to_value(&src_tab)),
313 });
314 }
315 if let Ok(dst_tab) = store.must_get::<Tab>(&target_tab_id) {
316 if let Ok(dst_layout) = store.must_get::<LayoutState>(&dst_tab.layoutstate) {
317 updates.push(MuxObjUpdate {
318 updatetype: "update".into(),
319 otype: OTYPE_LAYOUT.to_string(),
320 oid: dst_tab.layoutstate.clone(),
321 obj: Some(mux_obj_to_value(&dst_layout)),
322 });
323 }
324 updates.push(MuxObjUpdate {
325 updatetype: "update".into(),
326 otype: OTYPE_TAB.to_string(),
327 oid: target_tab_id.clone(),
328 obj: Some(mux_obj_to_value(&dst_tab)),
329 });
330 }
331 state.event_bus.broadcast_mux_obj_updates(&updates);
341
342 WebReturnType::success_data_updates(
343 serde_json::json!({
344 "redocked": true,
345 "block_id": block_id,
346 "target_tab_id": target_tab_id,
347 }),
348 updates,
349 )
350}
351
352async fn redock_as_tab(
358 state: &AppState,
359 target_tab_id: &str,
360 block_id: &str,
361 target_block_id: &str,
362) -> Result<(), String> {
363 let events = dispatch_to_reducer(
364 state,
365 agentmux_common::ipc::Command::LayoutStackPush {
366 tab_id: target_tab_id.to_string(),
367 target_block_id: target_block_id.to_string(),
368 block_id: block_id.to_string(),
369 activate: true,
370 correlation_id: String::new(),
371 },
372 )
373 .await;
374 if let Some(msg) = events.iter().find_map(|e| match e {
375 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
376 _ => None,
377 }) {
378 return Err(msg);
379 }
380 for ev in &events {
381 let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
382 }
383 publish_events(state, &events);
384 queue_target_stack_push(state, target_tab_id, block_id, target_block_id).await
385}
386
387pub(crate) async fn handle_tear_off_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
393 let store = &state.mstore;
394 let args = &call.args;
395 let tab_id: String = match service::get_arg(args, 0) {
396 Ok(v) => v,
397 Err(e) => return WebReturnType::error(e),
398 };
399 let source_ws_id: String = match service::get_arg(args, 1) {
400 Ok(v) => v,
401 Err(e) => return WebReturnType::error(e),
402 };
403 tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffTab via saga");
404 match crate::sagas::tear_off_tab::run(state, tab_id, source_ws_id.clone()).await {
405 Ok(saga_result) => {
406 let new_ws_oid = saga_result
407 .get("new_workspace_id")
408 .and_then(|v| v.as_str())
409 .unwrap_or_default()
410 .to_string();
411 let mut updates = Vec::new();
412 if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
413 updates.push(MuxObjUpdate {
414 updatetype: "update".into(),
415 otype: OTYPE_WORKSPACE.to_string(),
416 oid: source_ws_id.clone(),
417 obj: Some(mux_obj_to_value(&src_ws)),
418 });
419 }
420 if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
421 updates.push(MuxObjUpdate {
422 updatetype: "update".into(),
423 otype: OTYPE_WORKSPACE.to_string(),
424 oid: new_ws_oid.clone(),
425 obj: Some(mux_obj_to_value(&new_ws)),
426 });
427 }
428 WebReturnType::success_data_updates(
429 serde_json::to_value(&new_ws_oid).unwrap_or_default(),
430 updates,
431 )
432 }
433 Err(reason) => WebReturnType::error(reason),
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
442 use crate::server::tests::test_state;
443 use agentmux_common::ipc::{Command, Event};
444
445 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
446 let events = dispatch_to_reducer(state, cmd).await;
447 for ev in &events {
448 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
449 }
450 events
451 }
452
453 fn call(method: &str, args: Vec<serde_json::Value>) -> WebCallType {
454 WebCallType { service: "workspace".into(), method: method.into(), uicontext: None, args }
455 }
456
457 async fn two_tab_pane_and_a_neighbour(state: &AppState) -> (String, String, String, String, String) {
460 let ws_id = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() })
461 .await
462 .iter()
463 .find_map(|e| match e {
464 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
465 _ => None,
466 })
467 .unwrap();
468 let tab_id = dispatch_apply(state, Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() })
469 .await
470 .iter()
471 .find_map(|e| match e {
472 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
473 _ => None,
474 })
475 .unwrap();
476 let mut ids = Vec::new();
477 for _ in 0..3 {
478 let id = dispatch_apply(state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
479 .await
480 .iter()
481 .find_map(|e| match e {
482 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
483 _ => None,
484 })
485 .unwrap();
486 ids.push(id);
487 }
488 let (a, b, c) = (ids[0].clone(), ids[1].clone(), ids[2].clone());
489 dispatch_apply(
490 state,
491 Command::LayoutSetTree {
492 tab_id: tab_id.clone(),
493 new_tree: Some(agentmux_common::LayoutNode {
494 id: "root".into(),
495 children: vec![
496 agentmux_common::LayoutNode {
497 id: "pane-src".into(),
498 data: Some(agentmux_common::LayoutNodeData {
499 block_id: a.clone(),
500 block_stack: vec![a.clone(), b.clone()],
501 active_block_id: a.clone(),
502 ..Default::default()
503 }),
504 ..Default::default()
505 },
506 agentmux_common::LayoutNode {
507 id: "pane-other".into(),
508 data: Some(agentmux_common::LayoutNodeData { block_id: c.clone(), ..Default::default() }),
509 ..Default::default()
510 },
511 ],
512 ..Default::default()
513 }),
514 correlation_id: String::new(),
515 slices: None,
516 },
517 )
518 .await;
519 (ws_id, tab_id, a, b, c)
520 }
521
522 async fn tear_off(state: &AppState, block: &str, tab_id: &str, ws_id: &str) -> String {
523 let ret = handle_tear_off_block(
524 state,
525 &call("TearOffBlock", vec![block.into(), tab_id.into(), ws_id.into(), true.into()]),
526 )
527 .await;
528 assert!(ret.error.is_none(), "TearOffBlock failed: {:?}", ret.error);
529 ret.data.as_ref().and_then(|v| v.as_str()).expect("new workspace id").to_string()
530 }
531
532 async fn src_pane_members(state: &AppState, tab_id: &str) -> (Vec<String>, String, Vec<String>) {
533 let srv = state.srv_state.lock().await;
534 let tab = &srv.tabs[tab_id];
535 let root = tab.rootnode.as_ref().expect("source tree");
536 let src = crate::backend::layout::find_node_by_id(root, "pane-src").expect("source pane survives");
537 let data = src.data.as_ref().unwrap();
538 (crate::backend::layout::leaf_members(data), data.block_id.clone(), tab.block_ids.clone())
539 }
540
541 #[tokio::test]
542 async fn tearing_off_a_background_tab_keeps_its_pane_and_siblings_in_the_backend_tree() {
543 let state = test_state();
544 let (ws_id, tab_id, a, b, c) = two_tab_pane_and_a_neighbour(&state).await;
545
546 tear_off(&state, &b, &tab_id, &ws_id).await;
547
548 let (members, visible, blocks) = src_pane_members(&state, &tab_id).await;
549 assert_eq!(members, vec![a.clone()], "only the torn-off tab left the pane");
550 assert_eq!(visible, a);
551 assert!(blocks.contains(&a) && blocks.contains(&c) && !blocks.contains(&b));
552 let tab = state.mstore.must_get::<Tab>(&tab_id).unwrap();
554 let layout = state.mstore.must_get::<LayoutState>(&tab.layoutstate).unwrap();
555 assert!(
556 layout.pendingbackendactions.unwrap_or_default().iter().any(|x| x.actiontype == "delete" && x.blockid == b),
557 "queued frontend delete"
558 );
559 }
560
561 #[tokio::test]
562 async fn tearing_off_the_visible_tab_makes_its_neighbour_visible() {
563 let state = test_state();
564 let (ws_id, tab_id, a, b, _c) = two_tab_pane_and_a_neighbour(&state).await;
565
566 tear_off(&state, &a, &tab_id, &ws_id).await;
567
568 let (members, visible, _) = src_pane_members(&state, &tab_id).await;
569 assert_eq!(members, vec![b.clone()]);
570 assert_eq!(visible, b, "the right-hand neighbour becomes visible");
571 }
572
573 #[tokio::test]
574 async fn redock_as_tab_puts_a_torn_off_tab_back_into_its_pane() {
575 let state = test_state();
576 let (ws_id, tab_id, a, b, _c) = two_tab_pane_and_a_neighbour(&state).await;
577 let new_ws = tear_off(&state, &b, &tab_id, &ws_id).await;
578 let floater_tab = state.srv_state.lock().await.workspaces[&new_ws].tab_ids[0].clone();
579
580 let ret = handle_redock_floating_pane(
582 &state,
583 &call(
584 "RedockFloatingPane",
585 vec![
586 b.clone().into(),
587 floater_tab.into(),
588 new_ws.into(),
589 tab_id.clone().into(),
590 ws_id.into(),
591 a.clone().into(),
592 serde_json::Value::Null,
593 true.into(),
594 ],
595 ),
596 )
597 .await;
598 assert!(ret.error.is_none(), "redock failed: {:?}", ret.error);
599
600 let (members, visible, blocks) = src_pane_members(&state, &tab_id).await;
601 assert_eq!(members, vec![a, b.clone()], "back in its pane's stack, not a split");
602 assert_eq!(visible, b, "and visible");
603 assert!(blocks.contains(&b), "and back in the tab");
604 let tab = state.mstore.must_get::<Tab>(&tab_id).unwrap();
605 let layout = state.mstore.must_get::<LayoutState>(&tab.layoutstate).unwrap();
606 assert!(
607 layout.pendingbackendactions.unwrap_or_default().iter().any(|x| x.actiontype == "stackpush" && x.blockid == b),
608 "the window showing the pane is told to add the pill"
609 );
610 }
611}