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 Ok(json!({
166 "block_id": block_id,
167 "target_tab_id": target_tab_id,
168 "dst_index": dst_index,
169 }))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::backend::obj::{Block, Tab};
176 use crate::server::tests::test_state;
177 use agentmux_common::ipc::Event;
178
179 async fn dispatch_apply(
180 state: &crate::server::AppState,
181 cmd: agentmux_common::ipc::Command,
182 ) -> Vec<agentmux_common::ipc::Event> {
183 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
184 for ev in &events {
185 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
186 }
187 events
188 }
189
190 async fn seed_workspace_with_block(
191 state: &crate::server::AppState,
192 ws_name: &str,
193 ) -> (String, String, String) {
194 let ws_evs = dispatch_apply(
195 state,
196 Command::CreateWorkspace {
197 name: ws_name.into(),
198 },
199 )
200 .await;
201 let ws_id = ws_evs
202 .iter()
203 .find_map(|e| match e {
204 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
205 _ => None,
206 })
207 .unwrap();
208 let tab_evs = dispatch_apply(
209 state,
210 Command::CreateTab {
211 workspace_id: ws_id.clone(),
212 name: "t".into(),
213 },
214 )
215 .await;
216 let tab_id = tab_evs
217 .iter()
218 .find_map(|e| match e {
219 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
220 _ => None,
221 })
222 .unwrap();
223 let blk_evs = dispatch_apply(
224 state,
225 Command::CreateBlock {
226 tab_id: tab_id.clone(),
227 meta: Value::Null,
228 },
229 )
230 .await;
231 let block_id = blk_evs
232 .iter()
233 .find_map(|e| match e {
234 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
235 _ => None,
236 })
237 .unwrap();
238 (ws_id, tab_id, block_id)
239 }
240
241 #[tokio::test]
242 async fn happy_path_moves_block_between_workspaces() {
243 let state = test_state();
244 let (src_ws, src_tab, block_id) = seed_workspace_with_block(&state, "src").await;
245 let dst_ws_evs = dispatch_apply(
247 &state,
248 Command::CreateWorkspace { name: "dst".into() },
249 )
250 .await;
251 let dst_ws = dst_ws_evs
252 .iter()
253 .find_map(|e| match e {
254 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
255 _ => None,
256 })
257 .unwrap();
258 let dst_tab_evs = dispatch_apply(
259 &state,
260 Command::CreateTab {
261 workspace_id: dst_ws.clone(),
262 name: "dt".into(),
263 },
264 )
265 .await;
266 let dst_tab = dst_tab_evs
267 .iter()
268 .find_map(|e| match e {
269 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
270 _ => None,
271 })
272 .unwrap();
273
274 let result = run(
275 &state,
276 block_id.clone(),
277 src_tab.clone(),
278 src_ws.clone(),
279 dst_tab.clone(),
280 dst_ws.clone(),
281 None,
282 )
283 .await
284 .unwrap();
285 assert_eq!(result["block_id"], block_id);
286 assert_eq!(result["target_tab_id"], dst_tab);
287
288 let s = state.srv_state.lock().await;
290 assert!(s.tabs[&src_tab].block_ids.is_empty());
291 assert_eq!(s.tabs[&dst_tab].block_ids, vec![block_id.clone()]);
292 assert_eq!(s.blocks[&block_id].tab_id, dst_tab);
293
294 drop(s);
296 let dst_tab_obj = state.wstore.get::<Tab>(&dst_tab).unwrap().unwrap();
297 assert_eq!(dst_tab_obj.blockids, vec![block_id.clone()]);
298 let block = state.wstore.get::<Block>(&block_id).unwrap().unwrap();
299 assert_eq!(block.parentoref, format!("tab:{}", dst_tab));
300 }
301
302 #[tokio::test]
303 async fn rejects_when_source_and_target_are_same_tab() {
304 let state = test_state();
305 let (ws, tab, block_id) = seed_workspace_with_block(&state, "w").await;
306 let err = run(
307 &state,
308 block_id,
309 tab.clone(),
310 ws.clone(),
311 tab,
312 ws,
313 None,
314 )
315 .await
316 .unwrap_err();
317 assert!(
318 err.contains("source and target are the same tab"),
319 "got: {}",
320 err
321 );
322 }
323
324 #[tokio::test]
325 async fn rejects_when_block_not_in_source_tab() {
326 let state = test_state();
327 let (ws, tab, _block_id) = seed_workspace_with_block(&state, "w").await;
328 let dst_ws_evs = dispatch_apply(
329 &state,
330 Command::CreateWorkspace { name: "dst".into() },
331 )
332 .await;
333 let dst_ws = dst_ws_evs
334 .iter()
335 .find_map(|e| match e {
336 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
337 _ => None,
338 })
339 .unwrap();
340 let dst_tab_evs = dispatch_apply(
341 &state,
342 Command::CreateTab {
343 workspace_id: dst_ws.clone(),
344 name: "dt".into(),
345 },
346 )
347 .await;
348 let dst_tab = dst_tab_evs
349 .iter()
350 .find_map(|e| match e {
351 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
352 _ => None,
353 })
354 .unwrap();
355 let err = run(
356 &state,
357 "ghost-block".into(),
358 tab,
359 ws,
360 dst_tab,
361 dst_ws,
362 None,
363 )
364 .await
365 .unwrap_err();
366 assert!(err.contains("block not found"), "got: {}", err);
367 }
368}