1use agentmux_common::ipc::Command;
32use serde_json::{json, Value};
33
34use super::{
35 alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
36};
37use crate::server::AppState;
38
39pub async fn run(
47 state: &AppState,
48 block_id: String,
49 source_tab_id: String,
50 source_workspace_id: String,
51 target_tab_id: String,
52 target_workspace_id: String,
53 dst_index: Option<u32>,
54) -> Result<Value, String> {
55 let dst_index_resolved = {
60 let s = state.srv_state.lock().await;
61 match s.blocks.get(&block_id) {
62 None => {
63 return Err(format!(
64 "RedockFloatingPane: block not found: {}",
65 block_id
66 ));
67 }
68 Some(block) if block.tab_id != source_tab_id => {
69 return Err(format!(
70 "RedockFloatingPane: block {} is in tab {}, not {}",
71 block_id, block.tab_id, source_tab_id
72 ));
73 }
74 _ => {}
75 }
76 match s.tabs.get(&source_tab_id) {
77 None => {
78 return Err(format!(
79 "RedockFloatingPane: source tab not found: {}",
80 source_tab_id
81 ));
82 }
83 Some(tab) if tab.workspace_id != source_workspace_id => {
84 return Err(format!(
85 "RedockFloatingPane: source tab {} is in workspace {}, not {}",
86 source_tab_id, tab.workspace_id, source_workspace_id
87 ));
88 }
89 _ => {}
90 }
91 let target_tab = match s.tabs.get(&target_tab_id) {
92 None => {
93 return Err(format!(
94 "RedockFloatingPane: target tab not found: {}",
95 target_tab_id
96 ));
97 }
98 Some(tab) if tab.workspace_id != target_workspace_id => {
99 return Err(format!(
100 "RedockFloatingPane: target tab {} is in workspace {}, not {}",
101 target_tab_id, tab.workspace_id, target_workspace_id
102 ));
103 }
104 Some(tab) => tab,
105 };
106 if source_tab_id == target_tab_id {
107 return Err(
108 "RedockFloatingPane: source and target are the same tab — use MoveBlockToTab"
109 .to_string(),
110 );
111 }
112 dst_index.unwrap_or(target_tab.block_ids.len() as u32)
115 };
116
117 let saga_id = alloc_saga_id(state);
118 if let Err(e) = emit_saga_started(
119 state,
120 saga_id,
121 "redock_floating_pane",
122 json!({
123 "block_id": &block_id,
124 "source_tab_id": &source_tab_id,
125 "target_tab_id": &target_tab_id,
126 "dst_index": dst_index_resolved,
127 }),
128 )
129 .await
130 {
131 return Err(e);
132 }
133 let ctx = SagaCtx::new(state, saga_id);
134 let result = run_saga(
135 "redock_floating_pane",
136 run_inner(
137 ctx,
138 block_id,
139 source_tab_id,
140 target_tab_id,
141 dst_index_resolved,
142 ),
143 )
144 .await;
145 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
146 result
147}
148
149async fn run_inner(
150 ctx: SagaCtx<'_>,
151 block_id: String,
152 source_tab_id: String,
153 target_tab_id: String,
154 dst_index: u32,
155) -> Result<Value, String> {
156 ctx.dispatch(Command::MoveBlock {
157 block_id: block_id.clone(),
158 src_tab_id: source_tab_id,
159 dst_tab_id: target_tab_id.clone(),
160 dst_index,
161 })
162 .await
163 .map_err(|e| format!("RedockFloatingPane MoveBlock: {}", e))?;
164
165 if let Err(e) = ctx
171 .dispatch(Command::UpdateBlockMeta {
172 block_id: block_id.clone(),
173 meta_patch: json!({ "pane:floating_ontop": null }),
174 })
175 .await
176 {
177 tracing::warn!(block_id = %block_id, "RedockFloatingPane: clearing pane:floating_ontop failed: {e}");
178 }
179
180 Ok(json!({
181 "block_id": block_id,
182 "target_tab_id": target_tab_id,
183 "dst_index": dst_index,
184 }))
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::backend::obj::{Block, Tab};
191 use crate::server::tests::test_state;
192 use agentmux_common::ipc::Event;
193
194 async fn dispatch_apply(
195 state: &crate::server::AppState,
196 cmd: agentmux_common::ipc::Command,
197 ) -> Vec<agentmux_common::ipc::Event> {
198 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
199 for ev in &events {
200 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
201 }
202 events
203 }
204
205 async fn seed_workspace_with_block(
206 state: &crate::server::AppState,
207 ws_name: &str,
208 ) -> (String, String, String) {
209 let ws_evs = dispatch_apply(
210 state,
211 Command::CreateWorkspace {
212 name: ws_name.into(),
213 },
214 )
215 .await;
216 let ws_id = ws_evs
217 .iter()
218 .find_map(|e| match e {
219 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
220 _ => None,
221 })
222 .unwrap();
223 let tab_evs = dispatch_apply(
224 state,
225 Command::CreateTab {
226 workspace_id: ws_id.clone(),
227 name: "t".into(),
228 },
229 )
230 .await;
231 let tab_id = tab_evs
232 .iter()
233 .find_map(|e| match e {
234 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
235 _ => None,
236 })
237 .unwrap();
238 let blk_evs = dispatch_apply(
239 state,
240 Command::CreateBlock {
241 tab_id: tab_id.clone(),
242 meta: Value::Null,
243 },
244 )
245 .await;
246 let block_id = blk_evs
247 .iter()
248 .find_map(|e| match e {
249 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
250 _ => None,
251 })
252 .unwrap();
253 (ws_id, tab_id, block_id)
254 }
255
256 #[tokio::test]
257 async fn happy_path_moves_block_between_workspaces() {
258 let state = test_state();
259 let (src_ws, src_tab, block_id) = seed_workspace_with_block(&state, "src").await;
260 let dst_ws_evs = dispatch_apply(
262 &state,
263 Command::CreateWorkspace { name: "dst".into() },
264 )
265 .await;
266 let dst_ws = dst_ws_evs
267 .iter()
268 .find_map(|e| match e {
269 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
270 _ => None,
271 })
272 .unwrap();
273 let dst_tab_evs = dispatch_apply(
274 &state,
275 Command::CreateTab {
276 workspace_id: dst_ws.clone(),
277 name: "dt".into(),
278 },
279 )
280 .await;
281 let dst_tab = dst_tab_evs
282 .iter()
283 .find_map(|e| match e {
284 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
285 _ => None,
286 })
287 .unwrap();
288
289 let result = run(
290 &state,
291 block_id.clone(),
292 src_tab.clone(),
293 src_ws.clone(),
294 dst_tab.clone(),
295 dst_ws.clone(),
296 None,
297 )
298 .await
299 .unwrap();
300 assert_eq!(result["block_id"], block_id);
301 assert_eq!(result["target_tab_id"], dst_tab);
302
303 let s = state.srv_state.lock().await;
305 assert!(s.tabs[&src_tab].block_ids.is_empty());
306 assert_eq!(s.tabs[&dst_tab].block_ids, vec![block_id.clone()]);
307 assert_eq!(s.blocks[&block_id].tab_id, dst_tab);
308
309 drop(s);
311 let dst_tab_obj = state.mstore.get::<Tab>(&dst_tab).unwrap().unwrap();
312 assert_eq!(dst_tab_obj.blockids, vec![block_id.clone()]);
313 let block = state.mstore.get::<Block>(&block_id).unwrap().unwrap();
314 assert_eq!(block.parentoref, format!("tab:{}", dst_tab));
315 }
316
317 #[tokio::test]
318 async fn redock_clears_the_always_on_top_tack_and_keeps_other_meta() {
319 let state = test_state();
320 let (src_ws, src_tab, block_id) = seed_workspace_with_block(&state, "src").await;
321 dispatch_apply(
322 &state,
323 Command::UpdateBlockMeta {
324 block_id: block_id.clone(),
325 meta_patch: serde_json::json!({ "pane:floating_ontop": true, "frame:hue": 200 }),
326 },
327 )
328 .await;
329 let dst_ws = dispatch_apply(&state, Command::CreateWorkspace { name: "dst".into() })
330 .await
331 .iter()
332 .find_map(|e| match e {
333 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
334 _ => None,
335 })
336 .unwrap();
337 let dst_tab = dispatch_apply(
338 &state,
339 Command::CreateTab { workspace_id: dst_ws.clone(), name: "dt".into() },
340 )
341 .await
342 .iter()
343 .find_map(|e| match e {
344 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
345 _ => None,
346 })
347 .unwrap();
348
349 run(&state, block_id.clone(), src_tab, src_ws, dst_tab, dst_ws, None)
350 .await
351 .unwrap();
352
353 let block = state.mstore.get::<Block>(&block_id).unwrap().unwrap();
354 assert!(
355 !block.meta.contains_key("pane:floating_ontop"),
356 "redock must drop the tack: {:?}",
357 block.meta
358 );
359 assert_eq!(block.meta.get("frame:hue"), Some(&serde_json::json!(200)), "other meta is kept");
360 }
361
362 #[tokio::test]
363 async fn rejects_when_source_and_target_are_same_tab() {
364 let state = test_state();
365 let (ws, tab, block_id) = seed_workspace_with_block(&state, "w").await;
366 let err = run(
367 &state,
368 block_id,
369 tab.clone(),
370 ws.clone(),
371 tab,
372 ws,
373 None,
374 )
375 .await
376 .unwrap_err();
377 assert!(
378 err.contains("source and target are the same tab"),
379 "got: {}",
380 err
381 );
382 }
383
384 #[tokio::test]
385 async fn rejects_when_block_not_in_source_tab() {
386 let state = test_state();
387 let (ws, tab, _block_id) = seed_workspace_with_block(&state, "w").await;
388 let dst_ws_evs = dispatch_apply(
389 &state,
390 Command::CreateWorkspace { name: "dst".into() },
391 )
392 .await;
393 let dst_ws = dst_ws_evs
394 .iter()
395 .find_map(|e| match e {
396 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
397 _ => None,
398 })
399 .unwrap();
400 let dst_tab_evs = dispatch_apply(
401 &state,
402 Command::CreateTab {
403 workspace_id: dst_ws.clone(),
404 name: "dt".into(),
405 },
406 )
407 .await;
408 let dst_tab = dst_tab_evs
409 .iter()
410 .find_map(|e| match e {
411 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
412 _ => None,
413 })
414 .unwrap();
415 let err = run(
416 &state,
417 "ghost-block".into(),
418 tab,
419 ws,
420 dst_tab,
421 dst_ws,
422 None,
423 )
424 .await
425 .unwrap_err();
426 assert!(err.contains("block not found"), "got: {}", err);
427 }
428}