1use anyhow::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3use aimux_common::config::LayoutNode;
4use aimux_protocol::{LayoutListEntry, LayoutSource};
5
6use crate::session::{Layout, LayoutResult, SessionManager};
7use aimux_protocol::types::{PaneId, WindowId};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum SavedLayout {
16 Preset {
17 layout: String,
18 panes: Vec<SavedPane>,
19 },
20 Custom(LayoutNode),
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SavedPane {
25 #[serde(default)]
26 pub command: Option<String>,
27 #[serde(default)]
28 pub mark: Option<String>,
29}
30
31pub fn validate_layout_name(name: &str) -> Result<()> {
36 if name.is_empty() || name.len() > 64 {
37 bail!("layout name must be 1-64 characters, got {}", name.len());
38 }
39 if !name
40 .chars()
41 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
42 {
43 bail!(
44 "layout name must be alphanumeric, hyphens, or underscores: {}",
45 name
46 );
47 }
48 Ok(())
49}
50
51fn preset_to_string(layout: &Layout) -> String {
56 match layout {
57 Layout::Single => "single",
58 Layout::EvenHorizontal => "even-horizontal",
59 Layout::EvenVertical => "even-vertical",
60 Layout::MainVertical => "main-vertical",
61 Layout::Tiled => "tiled",
62 Layout::Custom(_) => unreachable!("custom handled separately"),
63 }
64 .to_string()
65}
66
67fn parse_preset_name(name: &str) -> Result<Layout> {
68 match name {
69 "single" => Ok(Layout::Single),
70 "even-horizontal" => Ok(Layout::EvenHorizontal),
71 "even-vertical" => Ok(Layout::EvenVertical),
72 "main-vertical" => Ok(Layout::MainVertical),
73 "tiled" => Ok(Layout::Tiled),
74 other => bail!("unknown preset layout: {}", other),
75 }
76}
77
78const PRESET_NAMES: &[&str] = &[
79 "single",
80 "even-horizontal",
81 "even-vertical",
82 "main-vertical",
83 "tiled",
84];
85
86fn build_saved_custom(
91 node: &crate::layout_dsl::LayoutTree,
92 panes: &[crate::session::Pane],
93 marks: &std::collections::HashMap<String, String>,
94 pane_idx: &mut usize,
95) -> LayoutNode {
96 match node {
97 crate::layout_dsl::LayoutTree::Leaf => {
98 let mut ln = LayoutNode {
99 size: None,
100 direction: None,
101 children: None,
102 command: None,
103 mark: None,
104 };
105 if let Some(pane) = panes.get(*pane_idx) {
106 ln.command = Some(pane.shell_command.clone());
107 let mark = marks
109 .iter()
110 .find(|(_, v)| v.as_str() == pane.id)
111 .map(|(k, _)| k.clone());
112 ln.mark = mark;
113 }
114 *pane_idx += 1;
115 ln
116 }
117 crate::layout_dsl::LayoutTree::Split {
118 direction,
119 children,
120 } => {
121 let dir = match direction {
122 aimux_common::config::SplitDirection::Horizontal => {
123 aimux_common::config::SplitDirection::Horizontal
124 }
125 aimux_common::config::SplitDirection::Vertical => {
126 aimux_common::config::SplitDirection::Vertical
127 }
128 };
129 let child_nodes: Vec<LayoutNode> = children
130 .iter()
131 .map(|(frac, child)| {
132 let mut ln = build_saved_custom(child, panes, marks, pane_idx);
133 ln.size = Some(format!("{}%", (frac * 100.0).round() as u32));
134 ln
135 })
136 .collect();
137 LayoutNode {
138 size: None,
139 direction: Some(dir),
140 children: Some(child_nodes),
141 command: None,
142 mark: None,
143 }
144 }
145 }
146}
147
148fn ensure_layouts_dir() -> Result<std::path::PathBuf> {
149 let dir = aimux_common::paths::layouts_dir();
150 #[cfg(unix)]
151 {
152 use std::os::unix::fs::DirBuilderExt;
153 std::fs::DirBuilder::new()
154 .recursive(true)
155 .mode(0o700)
156 .create(&dir)
157 .with_context(|| format!("failed to create layouts dir: {}", dir.display()))?;
158 }
159 #[cfg(not(unix))]
160 {
161 std::fs::create_dir_all(&dir)
162 .with_context(|| format!("failed to create layouts dir: {}", dir.display()))?;
163 }
164 Ok(dir)
165}
166
167impl SessionManager {
168 pub fn save_layout(
169 &self,
170 session_name: &str,
171 window_id: WindowId,
172 name: &str,
173 ) -> Result<()> {
174 validate_layout_name(name)?;
175
176 let session = self
177 .sessions
178 .get(session_name)
179 .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
180 let window = session
181 .find_window(window_id)
182 .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
183
184 let saved = match &window.layout {
185 Layout::Custom(tree) => {
186 let mut pane_idx = 0;
187 SavedLayout::Custom(build_saved_custom(
188 tree,
189 &window.panes,
190 &self.marks,
191 &mut pane_idx,
192 ))
193 }
194 preset => {
195 let layout_name = preset_to_string(preset);
196 let panes: Vec<SavedPane> = window
197 .panes
198 .iter()
199 .map(|p| SavedPane {
200 command: Some(p.shell_command.clone()),
201 mark: self
202 .marks
203 .iter()
204 .find(|(_, v)| v.as_str() == p.id)
205 .map(|(k, _)| k.clone()),
206 })
207 .collect();
208 SavedLayout::Preset {
209 layout: layout_name,
210 panes,
211 }
212 }
213 };
214
215 let dir = ensure_layouts_dir()?;
216 let path = dir.join(format!("{}.json", name));
217 let json = serde_json::to_string_pretty(&saved)
218 .context("failed to serialize layout")?;
219 std::fs::write(&path, json)
220 .with_context(|| format!("failed to write layout file: {}", path.display()))?;
221 Ok(())
222 }
223
224 pub fn load_layout(
225 &mut self,
226 session_name: &str,
227 window_id: WindowId,
228 name: &str,
229 ) -> LayoutResult {
230 validate_layout_name(name)?;
231
232 let saved = resolve_layout(name, self.config())?;
233
234 match saved {
235 SavedLayout::Preset { layout, panes } => {
236 self.load_preset_layout(session_name, window_id, &layout, &panes)
237 }
238 SavedLayout::Custom(node) => {
239 self.load_custom_layout(session_name, window_id, &node)
240 }
241 }
242 }
243
244 fn load_preset_layout(
245 &mut self,
246 session_name: &str,
247 window_id: WindowId,
248 layout_name: &str,
249 saved_panes: &[SavedPane],
250 ) -> LayoutResult {
251 let layout = parse_preset_name(layout_name)?;
252 let target_count = if saved_panes.is_empty() {
253 1
254 } else {
255 saved_panes.len()
256 };
257
258 let session = self
259 .sessions
260 .get(session_name)
261 .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
262 let default_shell = session.options.default_shell.clone();
263 let scrollback_limit = session.options.scrollback_limit;
264 let window = session
265 .find_window(window_id)
266 .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
267
268 let existing_pane_ids: Vec<PaneId> =
270 window.panes.iter().map(|p| p.id.clone()).collect();
271 for pane_id in &existing_pane_ids {
272 self.pane_index.remove(pane_id);
273 self.marks.retain(|_, v| v.as_str() != pane_id.as_str());
274 }
275 let killed_backends;
276 {
277 let session = self.sessions.get_mut(session_name).unwrap();
278 let window = session.find_window_mut(window_id).unwrap();
279 killed_backends = window.panes.drain(..).map(|p| p.pty).collect::<Vec<_>>();
280 }
281
282 let mut new_pane_ids = Vec::with_capacity(target_count);
284 for i in 0..target_count {
285 let shell = saved_panes
286 .get(i)
287 .and_then(|sp| sp.command.as_deref())
288 .unwrap_or(&default_shell);
289 let pane = self.create_pane(shell, 80, 24, scrollback_limit)?;
290 let pane_id = pane.id.clone();
291 self.pane_index
292 .insert(pane_id.clone(), (session_name.to_string(), window_id));
293 let session = self.sessions.get_mut(session_name).unwrap();
294 let window = session.find_window_mut(window_id).unwrap();
295 window.panes.push(pane);
296 new_pane_ids.push(pane_id);
297 }
298
299 {
301 let session = self.sessions.get_mut(session_name).unwrap();
302 let window = session.find_window_mut(window_id).unwrap();
303 window.layout = layout;
304 window.active_pane = 0;
305 }
306
307 for (i, sp) in saved_panes.iter().enumerate() {
309 if let Some(ref mark_name) = sp.mark {
310 if i < new_pane_ids.len() {
311 let _ = self.set_mark(mark_name, &new_pane_ids[i]);
312 }
313 }
314 }
315
316 Ok((new_pane_ids, killed_backends))
317 }
318
319 fn load_custom_layout(
320 &mut self,
321 session_name: &str,
322 window_id: WindowId,
323 node: &LayoutNode,
324 ) -> LayoutResult {
325 let temp_name = "__aimux_load_layout_temp__".to_string();
328
329 let layouts = self.config_mut().layouts.get_or_insert_with(Default::default);
331 layouts.insert(temp_name.clone(), node.clone());
332
333 let killed_backends;
335 {
336 let session = self
337 .sessions
338 .get(session_name)
339 .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
340 let window = session
341 .find_window(window_id)
342 .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
343 let existing_pane_ids: Vec<PaneId> =
344 window.panes.iter().map(|p| p.id.clone()).collect();
345 for pane_id in &existing_pane_ids {
346 self.pane_index.remove(pane_id);
347 self.marks.retain(|_, v| v.as_str() != pane_id.as_str());
348 }
349 let session = self.sessions.get_mut(session_name).unwrap();
350 let window = session.find_window_mut(window_id).unwrap();
351 killed_backends = window.panes.drain(..).map(|p| p.pty).collect::<Vec<_>>();
352 window.layout = Layout::Single;
353 }
354
355 {
357 let session = self.sessions.get(session_name).unwrap();
358 let default_shell = session.options.default_shell.clone();
359 let scrollback_limit = session.options.scrollback_limit;
360 let pane = self.create_pane(&default_shell, 80, 24, scrollback_limit)?;
361 let pane_id = pane.id.clone();
362 self.pane_index
363 .insert(pane_id, (session_name.to_string(), window_id));
364 let session = self.sessions.get_mut(session_name).unwrap();
365 let window = session.find_window_mut(window_id).unwrap();
366 window.panes.push(pane);
367 }
368
369 let (pane_ids, mut apply_backends) = self.apply_layout(session_name, window_id, &temp_name)?;
370
371 if let Some(ref mut layouts) = self.config_mut().layouts {
373 layouts.remove(&temp_name);
374 }
375
376 let mut all_backends = killed_backends;
378 all_backends.append(&mut apply_backends);
379 Ok((pane_ids, all_backends))
380 }
381
382 pub fn list_layouts(&self) -> Vec<LayoutListEntry> {
383 let mut entries = Vec::new();
384
385 if let Some(ref layouts) = self.config().layouts {
387 for name in layouts.keys() {
388 entries.push(LayoutListEntry {
389 name: name.clone(),
390 source: LayoutSource::Config,
391 });
392 }
393 }
394
395 let dir = aimux_common::paths::layouts_dir();
397 if let Ok(read_dir) = std::fs::read_dir(&dir) {
398 for entry in read_dir.flatten() {
399 let path = entry.path();
400 if path.extension().and_then(|e| e.to_str()) == Some("json") {
401 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
402 if !entries.iter().any(|e| e.name == stem) {
404 entries.push(LayoutListEntry {
405 name: stem.to_string(),
406 source: LayoutSource::Saved,
407 });
408 }
409 }
410 }
411 }
412 }
413
414 for &preset in PRESET_NAMES {
416 if !entries.iter().any(|e| e.name == preset) {
417 entries.push(LayoutListEntry {
418 name: preset.to_string(),
419 source: LayoutSource::Preset,
420 });
421 }
422 }
423
424 entries
425 }
426
427 pub fn resolve_layout_name(&self, name: &str) -> Result<()> {
430 let _ = resolve_layout(name, self.config())?;
433 Ok(())
434 }
435}
436
437fn resolve_layout(name: &str, config: &aimux_common::config::AimuxConfig) -> Result<SavedLayout> {
439 if let Some(ref layouts) = config.layouts {
441 if let Some(node) = layouts.get(name) {
442 return Ok(SavedLayout::Custom(node.clone()));
443 }
444 }
445
446 let dir = aimux_common::paths::layouts_dir();
448 let path = dir.join(format!("{}.json", name));
449 if path.exists() {
450 let json = std::fs::read_to_string(&path)
451 .with_context(|| format!("failed to read layout file: {}", path.display()))?;
452 let saved: SavedLayout = serde_json::from_str(&json)
453 .with_context(|| format!("failed to parse layout file: {}", path.display()))?;
454 return Ok(saved);
455 }
456
457 if parse_preset_name(name).is_ok() {
459 return Ok(SavedLayout::Preset {
460 layout: name.to_string(),
461 panes: vec![],
462 });
463 }
464
465 bail!(
466 "layout not found: {} (checked config, saved layouts, and presets)",
467 name
468 )
469}
470
471#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn validate_layout_name_valid() {
481 validate_layout_name("dev").unwrap();
482 validate_layout_name("my-layout").unwrap();
483 validate_layout_name("test_1").unwrap();
484 validate_layout_name("A").unwrap();
485 validate_layout_name(&"a".repeat(64)).unwrap();
486 }
487
488 #[test]
489 fn validate_layout_name_invalid() {
490 assert!(validate_layout_name("").is_err());
492 assert!(validate_layout_name(&"a".repeat(65)).is_err());
494 assert!(validate_layout_name("hello world").is_err());
496 assert!(validate_layout_name("foo/bar").is_err());
497 assert!(validate_layout_name("foo\\bar").is_err());
498 assert!(validate_layout_name("a..b").is_err());
499 assert!(validate_layout_name("foo@bar").is_err());
500 }
501
502 #[test]
503 fn saved_layout_preset_serde_roundtrip() {
504 let saved = SavedLayout::Preset {
505 layout: "even-horizontal".to_string(),
506 panes: vec![
507 SavedPane {
508 command: Some("nvim".to_string()),
509 mark: Some("editor".to_string()),
510 },
511 SavedPane {
512 command: Some("cargo watch".to_string()),
513 mark: None,
514 },
515 ],
516 };
517 let json = serde_json::to_string_pretty(&saved).unwrap();
518 let parsed: SavedLayout = serde_json::from_str(&json).unwrap();
519 match parsed {
520 SavedLayout::Preset { layout, panes } => {
521 assert_eq!(layout, "even-horizontal");
522 assert_eq!(panes.len(), 2);
523 assert_eq!(panes[0].command.as_deref(), Some("nvim"));
524 assert_eq!(panes[0].mark.as_deref(), Some("editor"));
525 assert_eq!(panes[1].command.as_deref(), Some("cargo watch"));
526 assert!(panes[1].mark.is_none());
527 }
528 _ => panic!("expected Preset variant"),
529 }
530 }
531
532 #[test]
533 fn saved_layout_custom_serde_roundtrip() {
534 let node = LayoutNode {
535 size: None,
536 direction: Some(aimux_common::config::SplitDirection::Horizontal),
537 children: Some(vec![
538 LayoutNode {
539 size: Some("60%".to_string()),
540 direction: None,
541 children: None,
542 command: Some("nvim".to_string()),
543 mark: Some("editor".to_string()),
544 },
545 LayoutNode {
546 size: Some("40%".to_string()),
547 direction: None,
548 children: None,
549 command: None,
550 mark: None,
551 },
552 ]),
553 command: None,
554 mark: None,
555 };
556 let saved = SavedLayout::Custom(node);
557 let json = serde_json::to_string_pretty(&saved).unwrap();
558 let parsed: SavedLayout = serde_json::from_str(&json).unwrap();
559 match parsed {
560 SavedLayout::Custom(n) => {
561 assert!(n.direction.is_some());
562 let children = n.children.unwrap();
563 assert_eq!(children.len(), 2);
564 assert_eq!(children[0].command.as_deref(), Some("nvim"));
565 }
566 _ => panic!("expected Custom variant"),
567 }
568 }
569
570 #[test]
571 fn preset_names_roundtrip() {
572 for &name in PRESET_NAMES {
573 let layout = parse_preset_name(name).unwrap();
574 let back = preset_to_string(&layout);
575 assert_eq!(back, name);
576 }
577 }
578
579 #[tokio::test]
580 async fn save_and_load_preset_layout() {
581 use crate::session::testing::new_manager;
582
583 let mut mgr = new_manager();
584 let (_, _p0) = mgr.new_session("s1", None).unwrap();
585 let _p1 = mgr.split_pane("%0", true, None).unwrap();
586
587 let tmp = std::env::temp_dir().join("aimux_test_layouts");
589 let _ = std::fs::remove_dir_all(&tmp);
590 std::fs::create_dir_all(&tmp).unwrap();
591
592 let session = mgr.sessions.get("s1").unwrap();
594 let window = &session.windows[0];
595 let saved = SavedLayout::Preset {
596 layout: "even-horizontal".to_string(),
597 panes: window
598 .panes
599 .iter()
600 .map(|p| SavedPane {
601 command: Some(p.shell_command.clone()),
602 mark: None,
603 })
604 .collect(),
605 };
606 let json = serde_json::to_string_pretty(&saved).unwrap();
607 std::fs::write(tmp.join("test-layout.json"), &json).unwrap();
608
609 let read_back: SavedLayout =
611 serde_json::from_str(&std::fs::read_to_string(tmp.join("test-layout.json")).unwrap())
612 .unwrap();
613 match read_back {
614 SavedLayout::Preset { layout, panes } => {
615 assert_eq!(layout, "even-horizontal");
616 assert_eq!(panes.len(), 2);
617 }
618 _ => panic!("expected Preset"),
619 }
620
621 let _ = std::fs::remove_dir_all(&tmp);
622 }
623
624 #[test]
625 fn list_layouts_includes_presets() {
626 use crate::session::testing::new_manager;
627
628 let mgr = new_manager();
629 let entries = mgr.list_layouts();
630 for &name in PRESET_NAMES {
632 assert!(
633 entries.iter().any(|e| e.name == name),
634 "missing preset: {}",
635 name
636 );
637 }
638 }
639}