1use agentmux_common::ipc::{Command, Event};
44use serde_json::{json, Value};
45
46use super::{
47 alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
48};
49use crate::server::AppState;
50
51pub async fn run(
55 state: &AppState,
56 tab_id: String,
57 source_workspace_id: String,
58) -> Result<Value, String> {
59 {
75 let src_ws = match state.wstore.get::<crate::backend::obj::Workspace>(&source_workspace_id) {
76 Ok(Some(ws)) => ws,
77 Ok(None) => {
78 return Err(format!(
79 "TearOffTab: source workspace not found: {}",
80 source_workspace_id
81 ));
82 }
83 Err(e) => return Err(format!("TearOffTab: workspace read failed: {}", e)),
84 };
85 let in_workspace = src_ws.tabids.iter().any(|id| id == &tab_id)
86 || src_ws.pinnedtabids.iter().any(|id| id == &tab_id);
87 if !in_workspace {
88 return Err(format!(
89 "TearOffTab: tab {} is not in workspace {}",
90 tab_id, source_workspace_id
91 ));
92 }
93 let total_tabs = src_ws.tabids.len() + src_ws.pinnedtabids.len();
94 if total_tabs <= 1 {
95 return Err(format!(
96 "TearOffTab: cannot tear off last tab from workspace {}",
97 source_workspace_id
98 ));
99 }
100 }
101
102 let saga_id = alloc_saga_id(state);
103 if let Err(e) = emit_saga_started(
104 state,
105 saga_id,
106 "tear_off_tab",
107 serde_json::json!({
108 "tab_id": &tab_id,
109 "source_workspace_id": &source_workspace_id,
110 }),
111 )
112 .await
113 {
114 return Err(e);
115 }
116 let ctx = SagaCtx::new(state, saga_id);
117 let result = run_saga("tear_off_tab", run_inner(ctx, tab_id, source_workspace_id)).await;
118 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
119 result
120}
121
122async fn run_inner(
123 ctx: SagaCtx<'_>,
124 tab_id: String,
125 source_workspace_id: String,
126) -> Result<Value, String> {
127 let create_events = ctx
129 .dispatch(Command::CreateWorkspace {
130 name: String::new(),
131 })
132 .await
133 .map_err(|e| format!("TearOffTab step 1 (CreateWorkspace): {}", e))?;
134 let new_workspace_id = create_events
135 .iter()
136 .find_map(|e| match e {
137 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
138 _ => None,
139 })
140 .ok_or_else(|| {
141 "TearOffTab: CreateWorkspace did not emit WorkspaceCreated".to_string()
142 })?;
143
144 if let Err(reason) = ctx
146 .dispatch(Command::MoveTab {
147 tab_id: tab_id.clone(),
148 src_workspace_id: source_workspace_id.clone(),
149 dst_workspace_id: new_workspace_id.clone(),
150 dst_index: 0,
151 })
152 .await
153 {
154 ctx.compensate(Command::DeleteWorkspace {
159 workspace_id: new_workspace_id.clone(),
160 force: false,
161 })
162 .await;
163 return Err(format!("TearOffTab step 2 (MoveTab): {}", reason));
164 }
165
166 Ok(json!({ "new_workspace_id": new_workspace_id }))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::backend::obj::Workspace;
173 use crate::server::tests::test_state;
174
175 async fn seed_workspace_with_two_tabs() -> (
179 crate::server::AppState,
180 String,
181 String,
182 String,
183 ) {
184 let state = test_state();
185 let ws_events = crate::server::service::dispatch_to_reducer(
189 &state,
190 agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
191 )
192 .await;
193 for ev in &ws_events {
194 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
195 }
196 let ws_id = ws_events
197 .iter()
198 .find_map(|e| match e {
199 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
200 _ => None,
201 })
202 .unwrap();
203 let mut tab_ids = Vec::new();
204 for name in &["tab-a", "tab-b"] {
205 let tab_events = crate::server::service::dispatch_to_reducer(
206 &state,
207 agentmux_common::ipc::Command::CreateTab {
208 workspace_id: ws_id.clone(),
209 name: name.to_string(),
210 },
211 )
212 .await;
213 for ev in &tab_events {
214 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
215 }
216 tab_ids.push(
217 tab_events
218 .iter()
219 .find_map(|e| match e {
220 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
221 _ => None,
222 })
223 .unwrap(),
224 );
225 }
226 (state, ws_id, tab_ids[0].clone(), tab_ids[1].clone())
227 }
228
229 #[tokio::test]
230 async fn happy_path_creates_new_workspace_and_moves_tab() {
231 let (state, src_ws, tab_a, tab_b) = seed_workspace_with_two_tabs().await;
232 let result = run(&state, tab_a.clone(), src_ws.clone()).await.unwrap();
233 let new_ws_id = result["new_workspace_id"].as_str().unwrap();
234
235 let s = state.srv_state.lock().await;
237 assert_eq!(s.workspaces[&src_ws].tab_ids, vec![tab_b.clone()]);
238 assert_eq!(s.workspaces[new_ws_id].tab_ids, vec![tab_a.clone()]);
239 assert_eq!(s.tabs[&tab_a].workspace_id, new_ws_id);
240
241 let src_persist = state.wstore.get::<Workspace>(&src_ws).unwrap().unwrap();
243 let new_persist = state.wstore.get::<Workspace>(new_ws_id).unwrap().unwrap();
244 assert_eq!(src_persist.tabids, vec![tab_b]);
245 assert_eq!(new_persist.tabids, vec![tab_a]);
246 }
247
248 #[tokio::test]
249 async fn rejects_when_source_workspace_missing() {
250 let state = test_state();
251 let err = run(&state, "tab-1".into(), "no-such-ws".into()).await.unwrap_err();
252 assert!(err.contains("source workspace not found"), "got: {}", err);
253 }
254
255 #[tokio::test]
256 async fn rejects_last_tab() {
257 let state = test_state();
258 let ws_events = crate::server::service::dispatch_to_reducer(
259 &state,
260 agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
261 )
262 .await;
263 for ev in &ws_events {
264 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
265 }
266 let ws_id = ws_events
267 .iter()
268 .find_map(|e| match e {
269 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
270 _ => None,
271 })
272 .unwrap();
273 let tab_events = crate::server::service::dispatch_to_reducer(
274 &state,
275 agentmux_common::ipc::Command::CreateTab {
276 workspace_id: ws_id.clone(),
277 name: "only".into(),
278 },
279 )
280 .await;
281 for ev in &tab_events {
282 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
283 }
284 let only_tab = tab_events
285 .iter()
286 .find_map(|e| match e {
287 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
288 _ => None,
289 })
290 .unwrap();
291 let err = run(&state, only_tab, ws_id).await.unwrap_err();
292 assert!(err.contains("last tab"), "got: {}", err);
293 }
294}