1use std::collections::{BTreeMap, HashMap};
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value};
24
25use agentmux_common::layout_types::LayoutNode;
26
27use crate::backend::obj::{Block, LayoutState, MetaMapType, Tab, Window, Workspace};
28use crate::backend::storage::store::Store;
29
30pub const LAYOUT_FORMAT: &str = "agentmux.layout";
32pub const LAYOUT_VERSION: u32 = 1;
34pub const LAYOUT_EXTENSION: &str = ".agentmux-layout.json";
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct LayoutDoc {
42 pub format: String,
43 pub version: u32,
44 #[serde(default)]
45 pub name: String,
46 #[serde(default, skip_serializing_if = "String::is_empty")]
47 pub saved_at: String,
48 #[serde(default)]
49 pub saved_by: SavedBy,
50 #[serde(default)]
51 pub scope: String,
52 #[serde(default)]
53 pub windows: Vec<LayoutWindow>,
54 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
55 pub extra: Map<String, Value>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59pub struct SavedBy {
60 #[serde(default)]
61 pub app: String,
62 #[serde(default)]
63 pub app_version: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub instance: Option<String>,
68 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
69 pub extra: Map<String, Value>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct LayoutWindow {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub active_tab: Option<usize>,
76 #[serde(default)]
77 pub tabs: Vec<LayoutTab>,
78 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
79 pub extra: Map<String, Value>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct LayoutTab {
84 #[serde(default)]
85 pub name: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub focused: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub magnified: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub root: Option<TreeNode>,
92 #[serde(default)]
94 pub panes: BTreeMap<String, LayoutPane>,
95 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
96 pub extra: Map<String, Value>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(untagged)]
102pub enum TreeNode {
103 Split {
104 split: String,
106 children: Vec<TreeChild>,
107 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
108 extra: Map<String, Value>,
109 },
110 Leaf {
111 pane: String,
112 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
113 extra: Map<String, Value>,
114 },
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct TreeChild {
119 pub ratio: f64,
121 pub node: TreeNode,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
125pub struct LayoutPane {
126 pub views: Vec<LayoutView>,
128 #[serde(default)]
130 pub active: usize,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub minimized: Option<bool>,
133 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
134 pub extra: Map<String, Value>,
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138pub struct LayoutView {
139 #[serde(rename = "type")]
140 pub view_type: String,
141 #[serde(default, skip_serializing_if = "Map::is_empty")]
142 pub config: Map<String, Value>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub resume: Option<Map<String, Value>>,
147 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
148 pub extra: Map<String, Value>,
149}
150
151pub struct ExportContext {
156 pub home: Option<PathBuf>,
158 pub app_version: String,
159 pub instance: Option<String>,
160 pub saved_at: String,
162 pub include_resume: bool,
164}
165
166impl ExportContext {
167 pub fn live(include_resume: bool) -> Self {
170 Self {
171 home: dirs::home_dir(),
172 app_version: env!("CARGO_PKG_VERSION").to_string(),
173 instance: crate::backend::storage::wan_identity::global()
174 .and_then(|w| w.instance_ensure(&crate::backend::reactive::registry::local_host_label()).ok())
175 .map(|i| i.instance_id),
176 saved_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
177 include_resume,
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq)]
185pub struct Exported {
186 pub doc: LayoutDoc,
187 pub warnings: Vec<String>,
188}
189
190pub fn export_window(store: &Store, window_id: &str, name: &str, ctx: &ExportContext) -> Result<Exported, String> {
192 let window = store
193 .get::<Window>(window_id)
194 .map_err(|e| format!("could not read window {window_id}: {e}"))?
195 .ok_or_else(|| format!("no such window: {window_id}"))?;
196 let workspace = store
197 .get::<Workspace>(&window.workspaceid)
198 .map_err(|e| format!("could not read the window's workspace: {e}"))?
199 .ok_or_else(|| "the window has no workspace".to_string())?;
200
201 let mut warnings = Vec::new();
202 let mut tabs = Vec::new();
203 let mut active_tab = None;
204 for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
207 let Some(tab) = store.get::<Tab>(tab_id).ok().flatten() else { continue };
208 let Some(exported) = export_tab(store, &tab, ctx, &mut warnings) else { continue };
209 if tab_id == &workspace.activetabid {
210 active_tab = Some(tabs.len());
211 }
212 tabs.push(exported);
213 }
214 if tabs.is_empty() {
215 return Err("the window has no tabs with panes to save".to_string());
216 }
217 Ok(Exported {
218 doc: LayoutDoc {
219 format: LAYOUT_FORMAT.to_string(),
220 version: LAYOUT_VERSION,
221 name: name.to_string(),
222 saved_at: ctx.saved_at.clone(),
223 saved_by: SavedBy {
224 app: "agentmux".to_string(),
225 app_version: ctx.app_version.clone(),
226 instance: ctx.instance.clone(),
227 extra: Map::new(),
228 },
229 scope: "window".to_string(),
230 windows: vec![LayoutWindow { active_tab, tabs, extra: Map::new() }],
231 extra: Map::new(),
232 },
233 warnings,
234 })
235}
236
237struct TabBuilder<'a> {
240 store: &'a Store,
241 ctx: &'a ExportContext,
242 blocks: HashMap<String, Block>,
243 panes: BTreeMap<String, LayoutPane>,
244 pane_by_node: HashMap<String, String>,
245 warnings: &'a mut Vec<String>,
246}
247
248fn export_tab(store: &Store, tab: &Tab, ctx: &ExportContext, warnings: &mut Vec<String>) -> Option<LayoutTab> {
249 let layout = if tab.layoutstate.is_empty() {
250 None
251 } else {
252 store.get::<LayoutState>(&tab.layoutstate).ok().flatten()
253 };
254 let rootnode = layout.as_ref().and_then(|l| l.rootnode.clone())?;
255 let blocks = tab
256 .blockids
257 .iter()
258 .filter_map(|id| store.get::<Block>(id).ok().flatten().map(|b| (id.clone(), b)))
259 .collect();
260 let mut builder = TabBuilder {
261 store,
262 ctx,
263 blocks,
264 panes: BTreeMap::new(),
265 pane_by_node: HashMap::new(),
266 warnings,
267 };
268 let root = builder.node(&rootnode)?;
269 let layout = layout?;
270 let focused = builder.pane_by_node.get(&layout.focusednodeid).cloned();
271 let magnified = builder.pane_by_node.get(&layout.magnifiednodeid).cloned();
272 Some(LayoutTab {
273 name: tab.name.clone(),
274 focused,
275 magnified,
276 root: Some(root),
277 panes: builder.panes,
278 extra: Map::new(),
279 })
280}
281
282impl TabBuilder<'_> {
283 fn node(&mut self, node: &LayoutNode) -> Option<TreeNode> {
286 if node.children.is_empty() {
287 return self.leaf(node);
288 }
289 let mut kept: Vec<(f64, TreeNode)> = Vec::new();
290 for child in &node.children {
291 if let Some(converted) = self.node(child) {
292 kept.push((f64::from(child.size.max(0.0)), converted));
293 }
294 }
295 match kept.len() {
296 0 => None,
297 1 => kept.pop().map(|(_, n)| n),
299 _ => Some(TreeNode::Split {
300 split: match node.flex_direction {
301 agentmux_common::layout_types::FlexDirection::Column => "column",
302 _ => "row",
303 }
304 .to_string(),
305 children: ratios(&kept.iter().map(|(s, _)| *s).collect::<Vec<_>>())
306 .into_iter()
307 .zip(kept.into_iter().map(|(_, n)| n))
308 .map(|(ratio, node)| TreeChild { ratio, node })
309 .collect(),
310 extra: Map::new(),
311 }),
312 }
313 }
314
315 fn leaf(&mut self, node: &LayoutNode) -> Option<TreeNode> {
316 let data = node.data.as_ref()?;
317 let stack: Vec<&String> = if data.block_stack.is_empty() {
318 vec![&data.block_id]
319 } else {
320 data.block_stack.iter().collect()
321 };
322 let active_block = if data.active_block_id.is_empty() { &data.block_id } else { &data.active_block_id };
323 let mut views = Vec::new();
324 let mut active = 0;
325 for block_id in stack {
326 let Some(block) = self.blocks.get(block_id) else { continue };
327 if block_id == active_block {
328 active = views.len();
329 }
330 views.push(view_from_block(self.store, &block.meta, self.ctx, self.warnings));
331 }
332 if views.is_empty() {
333 return None;
334 }
335 let pane_id = format!("p{}", self.panes.len() + 1);
336 self.pane_by_node.insert(node.id.clone(), pane_id.clone());
337 self.panes.insert(
338 pane_id.clone(),
339 LayoutPane {
340 views,
341 active,
342 minimized: node.extra.get("minimized").and_then(Value::as_bool).filter(|m| *m),
343 extra: Map::new(),
344 },
345 );
346 Some(TreeNode::Leaf { pane: pane_id, extra: Map::new() })
347 }
348}
349
350fn ratios(sizes: &[f64]) -> Vec<f64> {
354 let total: f64 = sizes.iter().sum();
355 let n = sizes.len();
356 let raw: Vec<f64> = if total > 0.0 {
357 sizes.iter().map(|s| s / total).collect()
358 } else {
359 vec![1.0 / n as f64; n]
360 };
361 let mut out: Vec<f64> = raw.iter().map(|r| (r * 10_000.0).round() / 10_000.0).collect();
362 if let Some(last) = out.last_mut() {
363 let rest: f64 = raw.iter().take(n - 1).map(|r| (r * 10_000.0).round() / 10_000.0).sum();
364 *last = ((1.0 - rest) * 10_000.0).round() / 10_000.0;
365 }
366 out
367}
368
369fn armory_target(section: Option<&str>, subsection: Option<&str>) -> (&'static str, &'static str) {
374 match section {
375 Some("mcp") => ("connectors", "mcp"),
376 Some("memory") if subsection == Some("personal") => ("memory", "personal"),
377 Some("memory") => ("memory", "global"),
378 Some("native_memory") => ("memory", "personal"),
379 Some("skills") => ("memory", "skills"),
380 Some("bundles") => ("memory", "bundles"),
381 _ => ("connectors", "accounts"),
382 }
383}
384
385fn meta_str<'a>(meta: &'a MetaMapType, key: &str) -> Option<&'a str> {
386 meta.get(key).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
387}
388
389fn home_relative(path: &str, home: Option<&Path>) -> String {
391 let Some(home) = home.and_then(|h| h.to_str()) else { return path.to_string() };
392 let home = home.trim_end_matches(['/', '\\']);
393 let (Some(prefix), Some(rest)) = (path.get(..home.len()), path.get(home.len()..)) else {
397 return path.to_string();
398 };
399 let matches_prefix = if cfg!(windows) { prefix.eq_ignore_ascii_case(home) } else { prefix == home };
400 if !matches_prefix {
401 return path.to_string();
402 }
403 if rest.is_empty() {
404 return "~".to_string();
405 }
406 if !rest.starts_with(['/', '\\']) {
407 return path.to_string(); }
409 format!("~{}", rest.replace('\\', "/"))
410}
411
412fn strip_query(url: &str) -> String {
415 url.split(['?', '#']).next().unwrap_or("").to_string()
416}
417
418fn view_from_block(store: &Store, meta: &MetaMapType, ctx: &ExportContext, warnings: &mut Vec<String>) -> LayoutView {
423 let mut view_type = meta_str(meta, "view").unwrap_or("unknown").to_string();
424 let home = ctx.home.as_deref();
425 let mut config = Map::new();
426 let mut resume = None;
427 if matches!(view_type.as_str(), "armory" | "trust") {
430 let (pane, section) = armory_target(meta_str(meta, "armory:section"), meta_str(meta, "armory:memory:subsection"));
431 view_type = pane.to_string();
432 config.insert("section".into(), Value::String(section.to_string()));
433 }
434 if view_type == "knowledge" {
437 view_type = "memory".to_string();
438 if let Some(section) = meta_str(meta, "memory:section").or_else(|| meta_str(meta, "knowledge:section")) {
439 config.insert("section".into(), Value::String(section.to_string()));
440 }
441 }
442 if view_type == "remotes" {
444 view_type = "connectors".to_string();
445 config.insert("section".into(), Value::String("remotes".to_string()));
446 }
447 match view_type.as_str() {
448 "agent" => {
449 let def = meta_str(meta, "agentId").and_then(|id| store.agent_def_get(id).ok().flatten());
451 let slug = def.as_ref().map(|d| d.slug.clone()).filter(|s| !s.is_empty());
452 let name = def
453 .as_ref()
454 .map(|d| d.name.clone())
455 .filter(|s| !s.is_empty())
456 .or_else(|| meta_str(meta, "agentName").map(str::to_string));
457 let provider = def
458 .as_ref()
459 .map(|d| d.provider.clone())
460 .filter(|s| !s.is_empty())
461 .or_else(|| meta_str(meta, "agentProvider").map(str::to_string));
462 if let Some(slug) = slug {
463 config.insert("agent".into(), Value::String(slug));
464 }
465 if let Some(name) = name {
466 config.insert("name".into(), Value::String(name));
467 }
468 if let Some(provider) = provider {
469 config.insert("provider".into(), Value::String(provider));
470 }
471 if ctx.include_resume {
472 if let Some(session) = meta_str(meta, "agent:sessionid") {
473 let mut r = Map::new();
474 r.insert("session_id".into(), Value::String(session.to_string()));
475 resume = Some(r);
476 }
477 }
478 }
479 "term" => {
480 if let Some(cwd) = meta_str(meta, "cmd:cwd") {
481 config.insert("cwd".into(), Value::String(home_relative(cwd, home)));
482 }
483 if let Some(cmd) = meta_str(meta, "cmd") {
484 let args: Vec<String> = meta
485 .get("cmd:args")
486 .and_then(Value::as_array)
487 .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
488 .unwrap_or_default();
489 let full = std::iter::once(cmd.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
490 if crate::backend::reactive::sanitize::is_sensitive_message(&full) {
491 warnings.push(format!(
492 "A terminal command looks like it may contain a credential or a destructive operation: `{}` — check the file before sharing it.",
493 crate::backend::reactive::sanitize::sanitize_message(&full)
494 ));
495 }
496 config.insert("command".into(), Value::String(cmd.to_string()));
497 if !args.is_empty() {
498 config.insert("args".into(), Value::Array(args.into_iter().map(Value::String).collect()));
499 }
500 }
501 }
502 "browser" => {
503 if let Some(url) = meta_str(meta, "url") {
504 config.insert("url".into(), Value::String(strip_query(url)));
505 }
506 }
507 "editor" | "codeeditor" | "preview" => {
508 if let Some(file) = meta_str(meta, "file") {
509 config.insert("file".into(), Value::String(home_relative(file, home)));
510 }
511 }
512 "media" => {
513 if let Some(path) = meta_str(meta, "media:path") {
514 config.insert("path".into(), Value::String(home_relative(path, home)));
515 }
516 }
517 "files" => {
518 if let Some(path) = meta_str(meta, "files:path") {
519 config.insert("path".into(), Value::String(home_relative(path, home)));
520 }
521 }
522 "sysinfo" => {
523 if let Some(kind) = meta_str(meta, "sysinfo:type") {
524 config.insert("sysinfo_type".into(), Value::String(kind.to_string()));
525 }
526 }
527 "connectors" | "memory" => {
528 if let Some(section) = meta_str(meta, &format!("{view_type}:section")) {
529 config.insert("section".into(), Value::String(section.to_string()));
530 }
531 }
532 _ => {}
535 }
536 LayoutView { view_type, config, resume, extra: Map::new() }
537}
538
539pub fn is_layout_path(path: &Path) -> bool {
544 path.is_absolute()
545 && path
546 .file_name()
547 .and_then(|n| n.to_str())
548 .is_some_and(|n| n.to_ascii_lowercase().ends_with(LAYOUT_EXTENSION) && n.len() > LAYOUT_EXTENSION.len())
549}
550
551pub fn write_layout_file(path: &Path, doc: &LayoutDoc) -> Result<(), String> {
554 if !is_layout_path(path) {
555 return Err(format!(
556 "a layout must be saved to an absolute path ending in {LAYOUT_EXTENSION}: {}",
557 path.display()
558 ));
559 }
560 let dir = path.parent().ok_or_else(|| format!("no parent directory: {}", path.display()))?;
561 std::fs::create_dir_all(dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
562 let mut json = serde_json::to_string_pretty(doc).map_err(|e| format!("could not encode the layout: {e}"))?;
563 json.push('\n');
564 let tmp = dir.join(format!(".{}.tmp-{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("layout"), uuid::Uuid::new_v4()));
565 std::fs::write(&tmp, json).map_err(|e| format!("could not write {}: {e}", tmp.display()))?;
566 std::fs::rename(&tmp, path).map_err(|e| {
567 let _ = std::fs::remove_file(&tmp);
568 format!("could not save {}: {e}", path.display())
569 })
570}
571
572pub fn parse_layout(json: &str) -> Result<LayoutDoc, String> {
575 let doc: LayoutDoc = serde_json::from_str(json).map_err(|e| format!("not a layout file: {e}"))?;
576 if doc.format != LAYOUT_FORMAT {
577 return Err(format!("not an AgentMux layout (format is \"{}\")", doc.format));
578 }
579 if doc.version > LAYOUT_VERSION {
580 return Err(format!(
581 "this layout was saved by a newer AgentMux (format version {}; this build reads up to {LAYOUT_VERSION})",
582 doc.version
583 ));
584 }
585 Ok(doc)
586}
587
588pub const META_LAYOUT_AGENT: &str = "layout:agent";
594
595pub const MAX_LAYOUT_FILE_BYTES: u64 = 1024 * 1024;
597const MAX_TABS: usize = 50;
598const MAX_PANES_PER_TAB: usize = 64;
599const MAX_TREE_DEPTH: usize = 32;
600
601const PLAIN_VIEWS: &[&str] = &[
603 "swarm", "settings", "help", "drone", "workflows", "warden", "toolchain", "launcher", "identity",
604 "tower",
605];
606
607pub struct PlanOptions {
609 pub home: Option<PathBuf>,
611 pub run_commands: bool,
614}
615
616#[derive(Debug, Clone)]
619pub struct PlanTab {
620 pub name: String,
621 pub blocks: Vec<Value>,
622 pub rootnode: Option<LayoutNode>,
623 pub focused: String,
624 pub magnified: String,
625 pub summary: Vec<String>,
627}
628
629#[derive(Debug, Clone, Default)]
632pub struct ApplyPlan {
633 pub name: String,
634 pub tabs: Vec<PlanTab>,
635 pub active_tab: Option<usize>,
636 pub commands: Vec<String>,
638 pub notes: Vec<String>,
640}
641
642pub fn read_layout_file(path: &Path) -> Result<LayoutDoc, String> {
644 let meta = std::fs::metadata(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
645 if meta.len() > MAX_LAYOUT_FILE_BYTES {
646 return Err(format!("{} is too large to be a layout file", path.display()));
647 }
648 let text = std::fs::read_to_string(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
649 parse_layout(&text)
650}
651
652fn expand_home(path: &str, home: Option<&Path>) -> PathBuf {
654 match (path.strip_prefix('~'), home) {
655 (Some(""), Some(h)) => h.to_path_buf(),
656 (Some(rest), Some(h)) if rest.starts_with(['/', '\\']) => h.join(rest.trim_start_matches(['/', '\\'])),
657 _ => PathBuf::from(path),
658 }
659}
660
661fn cfg_str<'a>(view: &'a LayoutView, key: &str) -> Option<&'a str> {
662 view.config.get(key).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
663}
664
665fn resolve_agent<'a>(
669 agents: &'a [crate::backend::storage::agents::AgentDefinition],
670 slug: Option<&str>,
671 name: Option<&str>,
672) -> Option<&'a crate::backend::storage::agents::AgentDefinition> {
673 fn unique<'a, T>(mut it: impl Iterator<Item = &'a T>) -> Option<&'a T> {
674 let first = it.next()?;
675 it.next().is_none().then_some(first)
676 }
677 let users = || agents.iter().filter(|a| a.is_seeded == 0);
678 slug.and_then(|s| unique(users().filter(|a| a.slug.eq_ignore_ascii_case(s))))
679 .or_else(|| name.and_then(|n| unique(users().filter(|a| a.name.eq_ignore_ascii_case(n)))))
680}
681
682pub fn plan_from_doc(store: &Store, doc: &LayoutDoc, opts: &PlanOptions) -> ApplyPlan {
684 let agents = store.agent_def_list().unwrap_or_default();
685 let mut plan = ApplyPlan { name: doc.name.clone(), ..Default::default() };
686 let Some(window) = doc.windows.first() else {
687 plan.notes.push("The file has no windows.".to_string());
688 return plan;
689 };
690 if doc.windows.len() > 1 {
691 plan.notes.push(format!("The file has {} windows; only the first is opened.", doc.windows.len()));
692 }
693 if window.tabs.len() > MAX_TABS {
694 plan.notes.push(format!("Only the first {MAX_TABS} of {} tabs are opened.", window.tabs.len()));
695 }
696 for (i, tab) in window.tabs.iter().take(MAX_TABS).enumerate() {
697 let mut builder = PlanBuilder {
698 doc_tab: tab,
699 agents: &agents,
700 opts,
701 blocks: Vec::new(),
702 summary: Vec::new(),
703 node_by_pane: HashMap::new(),
704 panes_used: 0,
705 truncated: Vec::new(),
706 commands: &mut plan.commands,
707 notes: &mut plan.notes,
708 };
709 let rootnode = tab.root.as_ref().and_then(|root| builder.node(root, 100.0, 0));
710 let (blocks, summary, node_by_pane) = (builder.blocks, builder.summary, builder.node_by_pane);
711 if blocks.is_empty() {
712 plan.notes.push(format!("Tab “{}” has no panes that can be opened, so it is skipped.", tab.name));
713 continue;
714 }
715 if window.active_tab == Some(i) {
716 plan.active_tab = Some(plan.tabs.len());
717 }
718 let node_for = |pane: &Option<String>| pane.as_ref().and_then(|p| node_by_pane.get(p)).cloned().unwrap_or_default();
719 plan.tabs.push(PlanTab {
720 name: tab.name.clone(),
721 blocks,
722 rootnode,
723 focused: node_for(&tab.focused),
724 magnified: node_for(&tab.magnified),
725 summary,
726 });
727 }
728 plan
729}
730
731struct PlanBuilder<'a> {
732 doc_tab: &'a LayoutTab,
733 agents: &'a [crate::backend::storage::agents::AgentDefinition],
734 opts: &'a PlanOptions,
735 blocks: Vec<Value>,
736 summary: Vec<String>,
737 node_by_pane: HashMap<String, String>,
738 panes_used: usize,
739 truncated: Vec<Truncated>,
740 commands: &'a mut Vec<String>,
741 notes: &'a mut Vec<String>,
742}
743
744#[derive(PartialEq)]
747enum Truncated {
748 Depth,
749 Panes,
750}
751
752impl PlanBuilder<'_> {
753 fn note_once(&mut self, what: Truncated) {
754 if self.truncated.contains(&what) {
755 return;
756 }
757 let tab = &self.doc_tab.name;
758 self.notes.push(match what {
759 Truncated::Depth => format!("Tab “{tab}” nests too deeply; the rest is skipped."),
760 Truncated::Panes => format!("Tab “{tab}” has more than {MAX_PANES_PER_TAB} panes; only the first {MAX_PANES_PER_TAB} are opened."),
761 });
762 self.truncated.push(what);
763 }
764
765 fn node(&mut self, node: &TreeNode, size: f64, depth: usize) -> Option<LayoutNode> {
766 if depth > MAX_TREE_DEPTH {
767 self.note_once(Truncated::Depth);
768 return None;
769 }
770 match node {
771 TreeNode::Split { split, children, .. } => {
772 let kids: Vec<LayoutNode> = children
773 .iter()
774 .filter_map(|c| self.node(&c.node, c.ratio.clamp(0.0, 1.0) * 100.0, depth + 1))
775 .collect();
776 match kids.len() {
777 0 => None,
778 1 => kids.into_iter().next().map(|mut only| {
779 only.size = size as f32;
780 only
781 }),
782 _ => Some(LayoutNode {
783 id: uuid::Uuid::new_v4().to_string(),
784 flex_direction: if split == "column" {
785 agentmux_common::layout_types::FlexDirection::Column
786 } else {
787 agentmux_common::layout_types::FlexDirection::Row
788 },
789 size: size as f32,
790 children: kids,
791 ..Default::default()
792 }),
793 }
794 }
795 TreeNode::Leaf { pane, .. } => {
796 if self.panes_used >= MAX_PANES_PER_TAB {
797 self.note_once(Truncated::Panes);
798 return None;
799 }
800 let tab = &self.doc_tab.name;
801 let Some(doc_pane) = self.doc_tab.panes.get(pane) else {
802 self.notes.push(format!("Tab “{tab}” refers to a pane “{pane}” the file doesn't define; it's left out."));
803 return None;
804 };
805 if doc_pane.views.is_empty() {
806 self.notes.push(format!("Pane “{pane}” in tab “{tab}” has nothing to show; it's left out."));
807 return None;
808 }
809 let mut placeholders = Vec::new();
810 for view in &doc_pane.views {
811 let meta = self.view_meta(view);
812 placeholders.push(crate::server::service::session_restore::placeholder(self.blocks.len()));
813 self.blocks.push(meta);
814 }
815 self.panes_used += 1;
816 let active = placeholders[doc_pane.active.min(placeholders.len() - 1)].clone();
817 let id = uuid::Uuid::new_v4().to_string();
818 self.node_by_pane.insert(pane.clone(), id.clone());
819 let mut extra = Map::new();
820 if doc_pane.minimized == Some(true) {
821 extra.insert("minimized".into(), Value::Bool(true));
822 }
823 let multi = placeholders.len() > 1;
824 Some(LayoutNode {
825 id,
826 size: size as f32,
827 data: Some(agentmux_common::layout_types::LayoutNodeData {
828 block_id: active.clone(),
829 block_stack: if multi { placeholders } else { Vec::new() },
830 active_block_id: if multi { active } else { String::new() },
831 ..Default::default()
832 }),
833 extra,
834 ..Default::default()
835 })
836 }
837 }
838 }
839
840 fn view_meta(&mut self, view: &LayoutView) -> Value {
842 let home = self.opts.home.as_deref();
843 let mut meta = Map::new();
844 let kind = view.view_type.as_str();
845 match kind {
846 "agent" => {
847 meta.insert("view".into(), "agent".into());
848 let slug = cfg_str(view, "agent");
849 let name = cfg_str(view, "name");
850 let label = name.or(slug).unwrap_or("agent");
851 match resolve_agent(self.agents, slug, name) {
852 Some(def) => {
853 meta.insert(META_LAYOUT_AGENT.into(), Value::String(def.id.clone()));
854 self.summary.push(format!("Agent: {}", def.name));
855 }
856 None => {
857 self.notes.push(format!("There's no agent “{label}” here — its pane opens the agent picker."));
858 self.summary.push(format!("Agent: {label} (not found — picker)"));
859 }
860 }
861 }
862 "term" => {
863 meta.insert("view".into(), "term".into());
864 let cwd = cfg_str(view, "cwd").map(|c| expand_home(c, home));
865 let mut where_ = String::new();
866 if let Some(cwd) = cwd {
867 if cwd.is_dir() {
868 where_ = format!(" in {}", cwd.display());
869 meta.insert("cmd:cwd".into(), Value::String(cwd.to_string_lossy().to_string()));
870 } else {
871 self.notes.push(format!("Folder {} doesn't exist here — that terminal starts in the default folder.", cwd.display()));
872 }
873 }
874 let command = cfg_str(view, "command").map(|c| {
875 let args: Vec<String> = view
876 .config
877 .get("args")
878 .and_then(Value::as_array)
879 .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
880 .unwrap_or_default();
881 (c.to_string(), args)
882 });
883 match command {
884 Some((cmd, args)) => {
885 let full = std::iter::once(cmd.clone()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
886 self.commands.push(full.clone());
887 if self.opts.run_commands {
888 meta.insert("controller".into(), "cmd".into());
889 meta.insert("cmd".into(), Value::String(cmd));
890 if !args.is_empty() {
891 meta.insert("cmd:args".into(), Value::Array(args.into_iter().map(Value::String).collect()));
892 }
893 self.summary.push(format!("Terminal{where_}: runs `{full}`"));
894 } else {
895 meta.insert("controller".into(), "shell".into());
896 self.summary.push(format!("Terminal{where_} (command not run: `{full}`)"));
897 }
898 }
899 None => {
900 meta.insert("controller".into(), "shell".into());
901 self.summary.push(format!("Terminal{where_}"));
902 }
903 }
904 }
905 "browser" => {
906 meta.insert("view".into(), "browser".into());
907 match cfg_str(view, "url") {
908 Some(url) if url.starts_with("https://") || url.starts_with("http://") => {
909 meta.insert("url".into(), Value::String(url.to_string()));
910 self.summary.push(format!("Browser: {url}"));
911 }
912 Some(url) => {
913 self.notes.push(format!("Skipped a browser address that isn't http(s): {url}"));
914 self.summary.push("Browser".to_string());
915 }
916 None => self.summary.push("Browser".to_string()),
917 }
918 }
919 "editor" | "codeeditor" | "preview" => {
920 meta.insert("view".into(), "editor".into());
921 match cfg_str(view, "file").map(|f| expand_home(f, home)) {
922 Some(file) => {
923 if !file.exists() {
924 self.notes.push(format!("File {} doesn't exist here.", file.display()));
925 }
926 self.summary.push(format!("Editor: {}", file.display()));
927 meta.insert("file".into(), Value::String(file.to_string_lossy().to_string()));
928 }
929 None => {
930 meta.insert("editor:scratch".into(), Value::Bool(true));
931 self.summary.push("Editor".to_string());
932 }
933 }
934 }
935 "media" => {
936 meta.insert("view".into(), "media".into());
937 if let Some(path) = cfg_str(view, "path").map(|p| expand_home(p, home)) {
938 self.summary.push(format!("Media: {}", path.display()));
939 meta.insert("media:path".into(), Value::String(path.to_string_lossy().to_string()));
940 } else {
941 self.summary.push("Media".to_string());
942 }
943 }
944 "files" => {
945 meta.insert("view".into(), "files".into());
946 if let Some(path) = cfg_str(view, "path").map(|p| expand_home(p, home)) {
947 self.summary.push(format!("Hangar: {}", path.display()));
948 meta.insert("files:path".into(), Value::String(path.to_string_lossy().to_string()));
949 } else {
950 self.summary.push("Hangar".to_string());
951 }
952 }
953 "sysinfo" => {
954 meta.insert("view".into(), "sysinfo".into());
955 if let Some(k) = cfg_str(view, "sysinfo_type") {
956 meta.insert("sysinfo:type".into(), Value::String(k.to_string()));
957 }
958 self.summary.push("System info".to_string());
959 }
960 "connectors" | "memory" | "knowledge" | "armory" | "remotes" => {
964 let (pane, section) = match kind {
965 "armory" => {
966 let (pane, section) = armory_target(cfg_str(view, "section"), None);
967 (pane, Some(section))
968 }
969 "knowledge" => ("memory", cfg_str(view, "section")),
970 "remotes" => ("connectors", Some("remotes")),
971 pane => (pane, cfg_str(view, "section")),
972 };
973 meta.insert("view".into(), pane.into());
974 if let Some(s) = section {
975 meta.insert(format!("{pane}:section"), Value::String(s.to_string()));
976 }
977 self.summary.push(if pane == "connectors" { "Connectors" } else { "Memory" }.to_string());
978 }
979 k if PLAIN_VIEWS.contains(&k) => {
980 meta.insert("view".into(), Value::String(k.to_string()));
981 self.summary.push(k.to_string());
982 }
983 other => {
984 let shown: String = other.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').take(40).collect();
987 self.notes.push(format!("Pane type “{shown}” isn't known to this version — it opens as an empty pane."));
988 meta.insert("view".into(), Value::String(shown.clone()));
989 self.summary.push(format!("{shown} (unknown)"));
990 }
991 }
992 Value::Object(meta)
993 }
994}
995
996pub fn is_trusted(doc: &LayoutDoc, path: &Path, own_instance: Option<&str>, layouts_dir: Option<&Path>) -> bool {
1000 let saved_here = own_instance.is_some_and(|own| doc.saved_by.instance.as_deref() == Some(own));
1001 let in_folder = layouts_dir.is_some_and(|dir| path.parent().is_some_and(|p| p == dir));
1002 saved_here && in_folder
1003}
1004
1005#[cfg(test)]
1006mod tests;