1use super::*;
14use crate::backend::layout_file;
15
16pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
17 let mstore = state.mstore.clone();
18 engine.register_typed(COMMAND_LAYOUT_SAVE, move |req: CommandLayoutSaveData, _ctx| {
19 let mstore = mstore.clone();
20 async move {
21 tokio::task::spawn_blocking(move || layout_save_impl(&mstore, req))
23 .await
24 .map_err(|e| format!("layout.save: task: {e}"))?
25 }
26 });
27
28 let mstore = state.mstore.clone();
29 engine.register_typed(COMMAND_LAYOUT_PREVIEW, move |req: CommandLayoutPreviewData, _ctx| {
30 let mstore = mstore.clone();
31 async move {
32 tokio::task::spawn_blocking(move || layout_preview_impl(&mstore, &req.path, &live_trust_inputs()))
33 .await
34 .map_err(|e| format!("layout.preview: task: {e}"))?
35 }
36 });
37
38 let open_state = state.clone();
39 engine.register_typed(COMMAND_LAYOUT_OPEN, move |req: CommandLayoutOpenData, _ctx| {
40 let state = open_state.clone();
41 async move { layout_open_impl(&state, req, &live_trust_inputs()).await }
42 });
43}
44
45pub(super) struct TrustInputs {
47 pub own_instance: Option<String>,
48 pub layouts_dir: Option<std::path::PathBuf>,
49 pub home: Option<std::path::PathBuf>,
50}
51
52fn live_trust_inputs() -> TrustInputs {
53 TrustInputs {
54 own_instance: crate::backend::storage::wan_identity::global()
55 .and_then(|w| w.instance_ensure(&crate::backend::reactive::registry::local_host_label()).ok())
56 .map(|i| i.instance_id),
57 layouts_dir: agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join("layouts")),
58 home: dirs::home_dir(),
59 }
60}
61
62fn load_plan(
65 store: &Store,
66 path: &str,
67 trust: &TrustInputs,
68 run_commands: Option<bool>,
69) -> Result<(layout_file::ApplyPlan, bool), String> {
70 let path = std::path::PathBuf::from(path);
71 if !layout_file::is_layout_path(&path) {
72 return Err(format!("the path must be absolute and end in {}", layout_file::LAYOUT_EXTENSION));
73 }
74 let doc = layout_file::read_layout_file(&path)?;
75 let trusted = layout_file::is_trusted(&doc, &path, trust.own_instance.as_deref(), trust.layouts_dir.as_deref());
76 let plan = layout_file::plan_from_doc(
77 store,
78 &doc,
79 &layout_file::PlanOptions { home: trust.home.clone(), run_commands: run_commands.unwrap_or(trusted) },
80 );
81 Ok((plan, trusted))
82}
83
84pub(super) fn layout_preview_impl(store: &Store, path: &str, trust: &TrustInputs) -> Result<LayoutPreviewResult, String> {
85 let (plan, trusted) = load_plan(store, path, trust, None).map_err(|e| format!("layout.preview: {e}"))?;
86 Ok(LayoutPreviewResult {
87 name: plan.name,
88 trusted,
89 tabs: plan
90 .tabs
91 .into_iter()
92 .map(|t| LayoutPreviewTab { name: t.name, panes: t.summary })
93 .collect(),
94 commands: plan.commands,
95 notes: plan.notes,
96 })
97}
98
99pub(super) async fn layout_open_impl(
105 state: &AppState,
106 req: CommandLayoutOpenData,
107 trust: &TrustInputs,
108) -> Result<LayoutOpenResult, String> {
109 let store = state.mstore.clone();
110 let path = req.path.clone();
111 let run = req.run_commands;
112 let trust_copy =
113 TrustInputs { own_instance: trust.own_instance.clone(), layouts_dir: trust.layouts_dir.clone(), home: trust.home.clone() };
114 let (plan, _trusted) = tokio::task::spawn_blocking(move || load_plan(&store, &path, &trust_copy, run))
115 .await
116 .map_err(|e| format!("layout.open: task: {e}"))?
117 .map_err(|e| format!("layout.open: {e}"))?;
118 if plan.tabs.is_empty() {
119 return Err("layout.open: the layout has no tabs that can be opened".to_string());
120 }
121 let new_window = req.new_window.unwrap_or(false);
122 let ws_id = if new_window {
123 let name = plan.name.trim();
125 create_empty_workspace(state, if name.is_empty() { "Layout" } else { name }).await?
126 } else {
127 state
128 .mstore
129 .get::<obj::Window>(&req.window_id)
130 .map_err(|e| format!("layout.open: {e}"))?
131 .ok_or_else(|| format!("layout.open: no such window: {}", req.window_id))?
132 .workspaceid
133 };
134
135 let replay: Vec<crate::server::service::session_restore::ReplayTab> = plan
136 .tabs
137 .iter()
138 .map(|t| crate::server::service::session_restore::ReplayTab {
139 name: t.name.clone(),
140 blocks: t.blocks.clone(),
141 rootnode: t.rootnode.clone(),
142 focused: t.focused.clone(),
143 magnified: t.magnified.clone(),
144 })
145 .collect();
146 let (events, replayed) =
147 crate::server::service::session_restore::replay_tabs(state, &ws_id, &replay, "layout.open").await;
148 crate::server::service::publish_events(state, &events);
149
150 let mut notes = plan.notes;
151 let tab_ids: Vec<String> = replayed.iter().flatten().map(|r| r.tab_id.clone()).collect();
152 if new_window && tab_ids.is_empty() {
153 let events = crate::server::service::dispatch_to_reducer(
155 state,
156 agentmux_common::ipc::Command::DeleteWorkspace { workspace_id: ws_id, force: false },
157 )
158 .await;
159 for ev in &events {
160 let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
161 }
162 crate::server::service::publish_events(state, &events);
163 return Err("layout.open: none of the layout's tabs could be created".to_string());
164 }
165 if tab_ids.len() < plan.tabs.len() {
166 notes.push(format!("{} of {} tabs couldn't be created.", plan.tabs.len() - tab_ids.len(), plan.tabs.len()));
167 }
168
169 if let Some(active) = plan.active_tab.and_then(|i| replayed.get(i)).and_then(|r| r.as_ref()) {
171 let events = crate::server::service::dispatch_to_reducer(
172 state,
173 agentmux_common::ipc::Command::SetActiveTab { workspace_id: ws_id.clone(), tab_id: active.tab_id.clone() },
174 )
175 .await;
176 for ev in &events {
177 let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
178 }
179 crate::server::service::publish_events(state, &events);
180 }
181
182 for tab in replayed.iter().flatten() {
184 for block_id in &tab.block_ids {
185 let Some(block) = state.mstore.get::<obj::Block>(block_id).ok().flatten() else { continue };
186 let def_id = obj::meta_get_string(&block.meta, layout_file::META_LAYOUT_AGENT, "");
187 if def_id.is_empty() {
188 continue;
189 }
190 if let Err(e) = super::agent_open::open_agent_into_block(state, &def_id, &tab.tab_id, block_id).await {
191 let name = obj::meta_get_string(&block.meta, "agentName", "");
192 let who = if name.is_empty() { def_id.clone() } else { name };
193 notes.push(format!("An agent ({who}) didn't start, so its pane shows the agent picker: {e}"));
194 }
195 }
196 }
197
198 Ok(LayoutOpenResult { workspace_id: ws_id, tab_ids, notes })
199}
200
201async fn create_empty_workspace(state: &AppState, name: &str) -> Result<String, String> {
206 use agentmux_common::ipc::{Command, Event};
207 let events = crate::server::service::dispatch_to_reducer(state, Command::CreateWorkspace { name: name.to_string() }).await;
208 if let Some(message) = events.iter().find_map(|e| match e {
209 Event::Error { message, .. } => Some(message.clone()),
210 _ => None,
211 }) {
212 return Err(format!("layout.open: {message}"));
213 }
214 for ev in &events {
215 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).map_err(|e| format!("layout.open: {e}"))?;
216 }
217 crate::server::service::publish_events(state, &events);
218 events
219 .iter()
220 .find_map(|e| match e {
221 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
222 _ => None,
223 })
224 .ok_or_else(|| "layout.open: no workspace was created".to_string())
225}
226
227pub(super) fn layout_save_impl(store: &Store, req: CommandLayoutSaveData) -> Result<LayoutSaveResult, String> {
228 let path = std::path::PathBuf::from(&req.path);
229 if !layout_file::is_layout_path(&path) {
230 return Err(format!(
231 "layout.save: the path must be absolute and end in {}",
232 layout_file::LAYOUT_EXTENSION
233 ));
234 }
235 let name = req.name.trim();
236 let name = if name.is_empty() { "Layout" } else { name };
237 let ctx = layout_file::ExportContext::live(req.include_resume.unwrap_or(false));
238 let exported =
239 layout_file::export_window(store, &req.window_id, name, &ctx).map_err(|e| format!("layout.save: {e}"))?;
240 layout_file::write_layout_file(&path, &exported.doc).map_err(|e| format!("layout.save: {e}"))?;
241 Ok(LayoutSaveResult { path: path.to_string_lossy().to_string(), warnings: exported.warnings })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::backend::obj::{Block, LayoutState, Tab, Window, Workspace};
248 use agentmux_common::layout_types::{LayoutNode, LayoutNodeData};
249
250 fn one_pane_window(store: &Store) {
251 let mut b = Block { oid: "b1".into(), ..Default::default() };
252 b.meta.insert("view".into(), serde_json::json!("sysinfo"));
253 store.insert(&mut b).unwrap();
254 let mut l = LayoutState {
255 oid: "l1".into(),
256 rootnode: Some(LayoutNode {
257 id: "n1".into(),
258 data: Some(LayoutNodeData { block_id: "b1".into(), ..Default::default() }),
259 ..Default::default()
260 }),
261 ..Default::default()
262 };
263 store.insert(&mut l).unwrap();
264 let mut t = Tab { oid: "t1".into(), name: "T".into(), layoutstate: "l1".into(), blockids: vec!["b1".into()], ..Default::default() };
265 store.insert(&mut t).unwrap();
266 let mut ws = Workspace { oid: "ws1".into(), tabids: vec!["t1".into()], activetabid: "t1".into(), ..Default::default() };
267 store.insert(&mut ws).unwrap();
268 let mut w = Window { oid: "w1".into(), workspaceid: "ws1".into(), ..Default::default() };
269 store.insert(&mut w).unwrap();
270 }
271
272 fn req(path: &std::path::Path, name: &str) -> CommandLayoutSaveData {
273 CommandLayoutSaveData {
274 window_id: "w1".into(),
275 name: name.into(),
276 path: path.to_string_lossy().to_string(),
277 include_resume: None,
278 }
279 }
280
281 #[test]
282 fn saves_the_window_to_the_requested_file() {
283 let store = Store::open_in_memory().unwrap();
284 one_pane_window(&store);
285 let dir = tempfile::tempdir().unwrap();
286 let path = dir.path().join("mine.agentmux-layout.json");
287 let out = layout_save_impl(&store, req(&path, " Mine ")).unwrap();
288 assert!(out.warnings.is_empty());
289 let doc = layout_file::parse_layout(&std::fs::read_to_string(&path).unwrap()).unwrap();
290 assert_eq!(doc.name, "Mine", "the name is trimmed");
291 assert_eq!(doc.windows[0].tabs[0].name, "T");
292 }
293
294 #[test]
295 fn a_blank_name_becomes_layout() {
296 let store = Store::open_in_memory().unwrap();
297 one_pane_window(&store);
298 let dir = tempfile::tempdir().unwrap();
299 let path = dir.path().join("x.agentmux-layout.json");
300 layout_save_impl(&store, req(&path, " ")).unwrap();
301 assert_eq!(layout_file::parse_layout(&std::fs::read_to_string(&path).unwrap()).unwrap().name, "Layout");
302 }
303
304 #[test]
305 fn refuses_any_other_destination_and_writes_nothing() {
306 let store = Store::open_in_memory().unwrap();
307 one_pane_window(&store);
308 let dir = tempfile::tempdir().unwrap();
309 let target = dir.path().join("settings.json");
310 std::fs::write(&target, "keep me").unwrap();
311 assert!(layout_save_impl(&store, req(&target, "x")).is_err());
312 assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
313 }
314
315 use crate::server::tests::test_state;
318 use agentmux_common::ipc::{Command, Event};
319
320 const OWN: &str = "gr2q7gf5lh6pzfdnurnkvputhm";
321
322 fn write_layout(dir: &std::path::Path, instance: &str) -> std::path::PathBuf {
327 let path = dir.join("work.agentmux-layout.json");
328 let doc = serde_json::json!({
329 "format": "agentmux.layout", "version": 1, "name": "Work",
330 "saved_by": { "app": "agentmux", "app_version": "x", "instance": instance },
331 "windows": [{ "active_tab": 0, "tabs": [{
332 "name": "Work",
333 "focused": "p2",
334 "root": { "split": "row", "children": [
335 { "ratio": 0.5, "node": { "pane": "p1" } },
336 { "ratio": 0.5, "node": { "pane": "p2" } }
337 ]},
338 "panes": {
339 "p1": { "views": [{ "type": "agent", "config": { "agent": "rev", "name": "Rev" } }] },
340 "p2": { "views": [
341 { "type": "term", "config": { "command": "echo", "args": ["hi"] } },
342 { "type": "sysinfo" }
343 ], "active": 0 }
344 }
345 }]}]
346 });
347 std::fs::write(&path, doc.to_string()).unwrap();
348 path
349 }
350
351 async fn window_with_workspace(state: &AppState) -> (String, String) {
352 let events = crate::server::service::dispatch_to_reducer(state, Command::CreateWorkspace { name: "w".into() }).await;
353 for ev in &events {
354 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
355 }
356 let ws_id = events
357 .iter()
358 .find_map(|e| match e {
359 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
360 _ => None,
361 })
362 .unwrap();
363 let mut w = Window { oid: "win-layout".into(), workspaceid: ws_id.clone(), ..Default::default() };
364 state.mstore.insert(&mut w).unwrap();
365 (w.oid, ws_id)
366 }
367
368 fn with_agent(state: &AppState) {
369 let mut d = crate::backend::storage::agents::test_agent_def("def-rev", "Rev", "no-such-provider", "agent", 1, "");
370 d.slug = "rev".into();
371 state.mstore.agent_def_insert(&mut d).unwrap();
372 }
373
374 fn trust(dir: &std::path::Path, own: Option<&str>) -> TrustInputs {
375 TrustInputs { own_instance: own.map(str::to_string), layouts_dir: Some(dir.to_path_buf()), home: Some(dir.to_path_buf()) }
376 }
377
378 #[tokio::test]
379 async fn preview_describes_without_opening() {
380 let state = test_state();
381 with_agent(&state);
382 let dir = tempfile::tempdir().unwrap();
383 let path = write_layout(dir.path(), "someone-else");
384 let before = state.mstore.get_all::<Tab>().unwrap().len();
385 let p = layout_preview_impl(&state.mstore, &path.to_string_lossy(), &trust(dir.path(), Some(OWN))).unwrap();
386 assert_eq!(p.name, "Work");
387 assert!(!p.trusted, "saved by another install");
388 assert_eq!(p.commands, ["echo hi"]);
389 assert_eq!(p.tabs.len(), 1);
390 assert_eq!(p.tabs[0].panes, ["Agent: Rev", "Terminal (command not run: `echo hi`)", "System info"]);
391 assert_eq!(state.mstore.get_all::<Tab>().unwrap().len(), before, "a preview creates nothing");
392 }
393
394 #[tokio::test]
395 async fn open_adds_the_tabs_to_the_window_and_holds_an_untrusted_command() {
396 let state = test_state();
397 with_agent(&state);
398 let (window_id, ws_id) = window_with_workspace(&state).await;
399 let dir = tempfile::tempdir().unwrap();
400 let path = write_layout(dir.path(), "someone-else");
401 let out = layout_open_impl(
402 &state,
403 CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id, run_commands: None, new_window: None },
404 &trust(dir.path(), Some(OWN)),
405 )
406 .await
407 .unwrap();
408
409 assert_eq!(out.tab_ids.len(), 1);
410 assert_eq!(out.workspace_id, ws_id);
411 let ws = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
412 assert!(ws.tabids.contains(&out.tab_ids[0]), "the tab lands in the window's own workspace");
413 let tab = state.mstore.get::<Tab>(&out.tab_ids[0]).unwrap().unwrap();
414 assert_eq!(tab.name, "Work");
415 assert_eq!(tab.blockids.len(), 3);
416
417 let blocks: Vec<Block> = tab.blockids.iter().map(|b| state.mstore.get::<Block>(b).unwrap().unwrap()).collect();
418 let view = |b: &Block| obj::meta_get_string(&b.meta, "view", "");
419 let term = blocks.iter().find(|b| view(b) == "term").unwrap();
420 assert_eq!(obj::meta_get_string(&term.meta, "controller", ""), "shell", "untrusted: the command is held");
421 assert!(!term.meta.contains_key("cmd"));
422 let agent = blocks.iter().find(|b| view(b) == "agent").unwrap();
424 assert!(obj::meta_get_string(&agent.meta, "agentId", "").is_empty());
425 assert!(out.notes.iter().any(|n| n.contains("didn't start")), "{:?}", out.notes);
426
427 let layout = state.mstore.get::<LayoutState>(&tab.layoutstate).unwrap().unwrap();
429 let json = serde_json::to_string(&layout.rootnode).unwrap();
430 assert!(!json.contains("__snap_block_"), "every placeholder resolved: {json}");
431 for b in &tab.blockids {
432 assert!(json.contains(b.as_str()), "block {b} is in the tree");
433 }
434 assert_eq!(layout.rootnode.as_ref().unwrap().children.len(), 2);
435 }
436
437 #[tokio::test]
438 async fn a_trusted_file_runs_its_command_and_an_explicit_choice_wins() {
439 let state = test_state();
440 let (window_id, _) = window_with_workspace(&state).await;
441 let dir = tempfile::tempdir().unwrap();
442 let path = write_layout(dir.path(), OWN);
443 let term_controller = |state: &AppState, tab_id: &str| {
444 let tab = state.mstore.get::<Tab>(tab_id).unwrap().unwrap();
445 tab.blockids
446 .iter()
447 .map(|b| state.mstore.get::<Block>(b).unwrap().unwrap())
448 .find(|b| obj::meta_get_string(&b.meta, "view", "") == "term")
449 .map(|b| obj::meta_get_string(&b.meta, "controller", ""))
450 .unwrap()
451 };
452
453 let trusted = layout_open_impl(
454 &state,
455 CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id: window_id.clone(), run_commands: None, new_window: None },
456 &trust(dir.path(), Some(OWN)),
457 )
458 .await
459 .unwrap();
460 assert_eq!(term_controller(&state, &trusted.tab_ids[0]), "cmd");
461
462 let declined = layout_open_impl(
463 &state,
464 CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id, run_commands: Some(false), new_window: None },
465 &trust(dir.path(), Some(OWN)),
466 )
467 .await
468 .unwrap();
469 assert_eq!(term_controller(&state, &declined.tab_ids[0]), "shell");
470 }
471
472 #[tokio::test]
473 async fn open_in_a_new_window_builds_its_own_workspace_and_leaves_this_one_alone() {
474 let state = test_state();
475 with_agent(&state);
476 let (_, ws_id) = window_with_workspace(&state).await;
477 let before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap().tabids;
478 let dir = tempfile::tempdir().unwrap();
479 let path = write_layout(dir.path(), "someone-else");
480 let out = layout_open_impl(
481 &state,
482 CommandLayoutOpenData {
484 path: path.to_string_lossy().to_string(),
485 window_id: String::new(),
486 run_commands: None,
487 new_window: Some(true),
488 },
489 &trust(dir.path(), Some(OWN)),
490 )
491 .await
492 .unwrap();
493
494 assert_ne!(out.workspace_id, ws_id);
495 assert_eq!(state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap().tabids, before, "the current window is untouched");
496 let ws = state.mstore.get::<Workspace>(&out.workspace_id).unwrap().unwrap();
497 assert_eq!(ws.name, "Work", "named after the layout");
498 assert_eq!(ws.tabids, out.tab_ids, "only the layout's tabs, no default tab");
499 assert_eq!(ws.activetabid, out.tab_ids[0]);
500 assert_eq!(
501 state.srv_state.lock().await.workspaces.get(&out.workspace_id).map(|w| w.tab_ids.clone()),
502 Some(out.tab_ids.clone()),
503 "the reducer knows the workspace, so a window can attach to it"
504 );
505 assert!(
506 state.mstore.get_all::<Window>().unwrap().iter().all(|w| w.workspaceid != out.workspace_id),
507 "no window yet; the caller opens one"
508 );
509
510 let tab = state.mstore.get::<Tab>(&out.tab_ids[0]).unwrap().unwrap();
512 let term = tab
513 .blockids
514 .iter()
515 .map(|b| state.mstore.get::<Block>(b).unwrap().unwrap())
516 .find(|b| obj::meta_get_string(&b.meta, "view", "") == "term")
517 .unwrap();
518 assert_eq!(obj::meta_get_string(&term.meta, "controller", ""), "shell");
519 }
520
521 #[tokio::test]
522 async fn a_new_windows_workspace_is_called_layout_when_the_file_has_no_name() {
523 let state = test_state();
524 let dir = tempfile::tempdir().unwrap();
525 let path = write_layout(dir.path(), OWN);
526 let mut doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
527 doc["name"] = serde_json::json!(" ");
528 std::fs::write(&path, doc.to_string()).unwrap();
529 let out = layout_open_impl(
530 &state,
531 CommandLayoutOpenData {
532 path: path.to_string_lossy().to_string(),
533 window_id: String::new(),
534 run_commands: Some(false),
535 new_window: Some(true),
536 },
537 &trust(dir.path(), Some(OWN)),
538 )
539 .await
540 .unwrap();
541 assert_eq!(state.mstore.get::<Workspace>(&out.workspace_id).unwrap().unwrap().name, "Layout");
542 }
543
544 #[tokio::test]
545 async fn open_refuses_a_non_layout_path_and_an_unknown_window() {
546 let state = test_state();
547 let dir = tempfile::tempdir().unwrap();
548 let bad = dir.path().join("settings.json");
549 std::fs::write(&bad, "{}").unwrap();
550 assert!(layout_preview_impl(&state.mstore, &bad.to_string_lossy(), &trust(dir.path(), None)).is_err());
551 let path = write_layout(dir.path(), OWN);
552 let err = layout_open_impl(
553 &state,
554 CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id: "nope".into(), run_commands: None, new_window: None },
555 &trust(dir.path(), None),
556 )
557 .await
558 .unwrap_err();
559 assert!(err.contains("no such window"), "{err}");
560 }
561
562 #[test]
563 fn deserializes_the_documented_request_shape() {
564 let r: CommandLayoutSaveData = serde_json::from_value(serde_json::json!({
565 "window_id": "w1", "name": "n", "path": "/tmp/a.agentmux-layout.json"
566 }))
567 .unwrap();
568 assert_eq!(r.include_resume, None);
569 }
570}