Skip to main content

aimux_server/
layout_save.rs

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// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13#[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
31// ---------------------------------------------------------------------------
32// Validation
33// ---------------------------------------------------------------------------
34
35pub 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
51// ---------------------------------------------------------------------------
52// Preset name mapping
53// ---------------------------------------------------------------------------
54
55fn 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
86// ---------------------------------------------------------------------------
87// Save
88// ---------------------------------------------------------------------------
89
90fn 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                // Find first mark pointing to this pane
108                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        // Kill existing panes — collect backends instead of closing
269        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        // Create new panes
283        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        // Set layout
300        {
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        // Apply marks
308        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        // Temporarily insert layout node into config, delegate to apply_layout,
326        // then remove it. This reuses the existing tree-based layout application.
327        let temp_name = "__aimux_load_layout_temp__".to_string();
328
329        // Insert into config layouts
330        let layouts = self.config_mut().layouts.get_or_insert_with(Default::default);
331        layouts.insert(temp_name.clone(), node.clone());
332
333        // Kill existing panes first — collect backends instead of closing
334        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        // Create one placeholder pane so apply_layout has something to work with
356        {
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        // Clean up temp config entry
372        if let Some(ref mut layouts) = self.config_mut().layouts {
373            layouts.remove(&temp_name);
374        }
375
376        // Combine backends from the killed panes and any from apply_layout
377        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        // 1. Config-defined layouts
386        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        // 2. Saved layouts from disk
396        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                        // Skip if already listed as config
403                        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        // 3. Built-in presets
415        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    /// Resolve a layout name: config → saved files → presets.
428    /// Used by --layout flag on session/window creation.
429    pub fn resolve_layout_name(&self, name: &str) -> Result<()> {
430        // Just validate that the name can be resolved; the actual application
431        // is handled by apply_layout or load_layout.
432        let _ = resolve_layout(name, self.config())?;
433        Ok(())
434    }
435}
436
437/// Resolution order: config → saved files → presets.
438fn resolve_layout(name: &str, config: &aimux_common::config::AimuxConfig) -> Result<SavedLayout> {
439    // 1. Config file's layouts section
440    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    // 2. Saved layouts directory
447    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    // 3. Built-in presets
458    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// ---------------------------------------------------------------------------
472// Tests
473// ---------------------------------------------------------------------------
474
475#[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        // Empty
491        assert!(validate_layout_name("").is_err());
492        // Too long
493        assert!(validate_layout_name(&"a".repeat(65)).is_err());
494        // Invalid chars
495        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        // Save using a temp dir
588        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        // Manually serialize and write
593        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        // Verify it was serialized correctly
610        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        // Should include all preset names
631        for &name in PRESET_NAMES {
632            assert!(
633                entries.iter().any(|e| e.name == name),
634                "missing preset: {}",
635                name
636            );
637        }
638    }
639}