diff options
Diffstat (limited to 'src/app/api/layouts.rs')
| -rw-r--r-- | src/app/api/layouts.rs | 758 |
1 files changed, 758 insertions, 0 deletions
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs new file mode 100644 index 00000000..e397ccf8 --- /dev/null +++ b/src/app/api/layouts.rs @@ -0,0 +1,758 @@ +use std::path::PathBuf; + +use ratatui::layout::Direction; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, LayoutApplyParams, LayoutDescription, LayoutExportParams, + LayoutNode, LayoutPane, ResponseResult, SplitDirection, +}; +use crate::app::{App, Mode}; +use crate::layout::{Node, PaneId}; +use crate::workspace::NewPane; + +use super::responses::{encode_error, encode_success}; + +const MAX_LAYOUT_PANES: usize = 24; +const MAX_LAYOUT_DEPTH: usize = 16; + +impl App { + pub(super) fn handle_layout_export( + &mut self, + id: String, + params: LayoutExportParams, + ) -> String { + let Some((ws_idx, tab_idx)) = self.resolve_layout_export_target(¶ms) else { + return encode_error(id, "layout_not_found", "layout target not found"); + }; + let Some(layout) = self.layout_description(ws_idx, tab_idx) else { + return encode_error(id, "layout_not_found", "layout unavailable"); + }; + + encode_success(id, ResponseResult::LayoutExport { layout }) + } + + pub(super) fn handle_layout_apply(&mut self, id: String, params: LayoutApplyParams) -> String { + let replace_target = match params.tab_id.as_deref() { + Some(tab_id) => match self.parse_tab_id(tab_id) { + Some(target) => Some(target), + None => { + return encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) + } + }, + None => None, + }; + if replace_target.is_some() && params.workspace_id.is_some() { + return encode_error( + id, + "invalid_target", + "use either tab_id or workspace_id, not both", + ); + } + + let ws_idx = if let Some((ws_idx, _)) = replace_target { + ws_idx + } else if let Some(workspace_id) = params.workspace_id.as_deref() { + let Some(ws_idx) = self.parse_workspace_id(workspace_id) else { + return encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ); + }; + ws_idx + } else if let Some(active) = self.state.active { + active + } else { + return encode_error(id, "workspace_not_found", "no active workspace"); + }; + if let Err(message) = validate_layout_tree(¶ms.root) { + return encode_error(id, "invalid_layout", message); + } + + let replacement_label = params.tab_label.clone().or_else(|| { + let (_, tab_idx) = replace_target?; + self.state + .workspaces + .get(ws_idx)? + .tabs + .get(tab_idx)? + .custom_name + .clone() + }); + let replace_was_active = replace_target.is_some_and(|(target_ws, target_tab)| { + self.state.active == Some(target_ws) + && self + .state + .workspaces + .get(target_ws) + .is_some_and(|ws| ws.active_tab_index() == target_tab) + }); + let root_leaf = first_layout_leaf(¶ms.root); + let first_cwd = self.layout_root_cwd(ws_idx, replace_target, root_leaf); + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let command = match layout_command(root_leaf) { + Ok(command) => command, + Err(message) => return encode_error(id, "invalid_layout", message), + }; + + let created = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + if let Some(argv) = command.as_deref() { + ws.create_tab_argv_command( + rows, + cols, + first_cwd, + argv, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + ) + } else { + ws.create_tab( + rows, + cols, + first_cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), + extra_env, + ) + } + }; + + let (new_tab_idx, terminal, runtime) = match created { + Ok(result) => result, + Err(err) => return encode_error(id, "layout_apply_failed", err.to_string()), + }; + let new_root_pane = self.state.workspaces[ws_idx].tabs[new_tab_idx].root_pane; + self.terminal_runtimes.insert(terminal.id.clone(), runtime); + self.state.remove_alias_shadowed_by_new_pane(new_root_pane); + self.state.terminals.insert(terminal.id.clone(), terminal); + if let Some(label) = replacement_label { + self.state.workspaces[ws_idx].tabs[new_tab_idx].set_custom_name(label); + } + self.apply_layout_pane_label(ws_idx, new_root_pane, root_leaf); + + if let Err(message) = self.apply_layout_node_to_pane(ws_idx, new_root_pane, ¶ms.root) { + self.rollback_layout_tab(ws_idx, new_root_pane); + return encode_error(id, "layout_apply_failed", message); + } + + if let Some((target_ws_idx, target_tab_idx)) = replace_target { + let closed_tab_id = self + .public_tab_id(target_ws_idx, target_tab_idx) + .unwrap_or_else(|| { + format!( + "{}:{}", + self.public_workspace_id(target_ws_idx), + target_tab_idx + 1 + ) + }); + let terminal_ids = self + .state + .terminal_ids_for_tab(target_ws_idx, target_tab_idx); + let Some(ws) = self.state.workspaces.get_mut(target_ws_idx) else { + return encode_error(id, "tab_not_found", "tab not found"); + }; + if ws.close_tab(target_tab_idx) { + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::TabClosed, + data: EventData::TabClosed { + tab_id: closed_tab_id, + workspace_id: self.public_workspace_id(target_ws_idx), + }, + }); + } + } + + let Some(new_tab_idx) = self.state.workspaces[ws_idx] + .tabs + .iter() + .position(|tab| tab.root_pane == new_root_pane) + else { + return encode_error(id, "layout_apply_failed", "new layout tab disappeared"); + }; + + if params.focus || replace_was_active { + self.state.switch_workspace_tab(ws_idx, new_tab_idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + if let Some(tab) = self.tab_info(ws_idx, new_tab_idx) { + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab }, + }); + } + for pane_id in self.state.workspaces[ws_idx].tabs[new_tab_idx] + .layout + .pane_ids() + { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane }, + }); + } + } + + let Some(layout) = self.layout_description(ws_idx, new_tab_idx) else { + return encode_error(id, "layout_apply_failed", "new layout unavailable"); + }; + encode_success(id, ResponseResult::LayoutApply { layout }) + } + + fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> { + match (params.tab_id.as_deref(), params.pane_id.as_deref()) { + (Some(_), Some(_)) => None, + (Some(tab_id), None) => self.parse_tab_id(tab_id), + (None, Some(pane_id)) => { + let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?; + let tab_idx = self + .state + .workspaces + .get(ws_idx)? + .find_tab_index_for_pane(pane_id)?; + Some((ws_idx, tab_idx)) + } + (None, None) => { + let ws_idx = self.state.active?; + let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index(); + Some((ws_idx, tab_idx)) + } + } + } + + fn layout_description(&self, ws_idx: usize, tab_idx: usize) -> Option<LayoutDescription> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + Some(LayoutDescription { + workspace_id: self.public_workspace_id(ws_idx), + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + zoomed: tab.zoomed, + focused_pane_id: self.public_pane_id(ws_idx, tab.layout.focused())?, + root: self.layout_node_description(ws_idx, tab_idx, tab.layout.root())?, + }) + } + + fn layout_node_description( + &self, + ws_idx: usize, + tab_idx: usize, + node: &Node, + ) -> Option<LayoutNode> { + match node { + Node::Pane(pane_id) => Some(LayoutNode::Pane { + pane: self.layout_pane_description(ws_idx, tab_idx, *pane_id)?, + }), + Node::Split { + direction, + ratio, + first, + second, + } => Some(LayoutNode::Split { + direction: match direction { + Direction::Horizontal => SplitDirection::Right, + Direction::Vertical => SplitDirection::Down, + }, + ratio: *ratio, + first: Box::new(self.layout_node_description(ws_idx, tab_idx, first)?), + second: Box::new(self.layout_node_description(ws_idx, tab_idx, second)?), + }), + } + } + + fn layout_pane_description( + &self, + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + ) -> Option<LayoutPane> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + let terminal_id = tab.terminal_id(pane_id)?; + let terminal = self.state.terminals.get(terminal_id); + Some(LayoutPane { + pane_id: Some(self.public_pane_id(ws_idx, pane_id)?), + label: terminal.and_then(|terminal| terminal.manual_label.clone()), + cwd: tab + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) + .map(|cwd| cwd.display().to_string()), + command: terminal.and_then(|terminal| terminal.launch_argv.clone()), + env: Default::default(), + }) + } + + fn layout_root_cwd( + &self, + ws_idx: usize, + replace_target: Option<(usize, usize)>, + pane: &LayoutPane, + ) -> PathBuf { + if let Some(cwd) = pane.cwd.as_ref() { + return PathBuf::from(cwd); + } + let follow_cwd = replace_target.and_then(|(_, tab_idx)| { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + tab.cwd_for_pane( + tab.layout.focused(), + &self.state.terminals, + &self.terminal_runtimes, + ) + }); + self.resolve_new_terminal_cwd(follow_cwd.or_else(|| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|runtime| runtime.cwd()) + })) + } + + fn apply_layout_node_to_pane( + &mut self, + ws_idx: usize, + pane_id: PaneId, + node: &LayoutNode, + ) -> Result<(), String> { + match node { + LayoutNode::Pane { pane } => { + self.apply_layout_pane_label(ws_idx, pane_id, pane); + Ok(()) + } + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + let second_leaf = first_layout_leaf(second); + let new_pane = self.layout_split_pane( + ws_idx, + pane_id, + direction.clone(), + *ratio, + second_leaf, + )?; + self.apply_layout_node_to_pane(ws_idx, pane_id, first)?; + self.apply_layout_node_to_pane(ws_idx, new_pane, second) + } + } + } + + fn layout_split_pane( + &mut self, + ws_idx: usize, + target_pane_id: PaneId, + direction: SplitDirection, + ratio: f32, + pane: &LayoutPane, + ) -> Result<PaneId, String> { + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let cwd = pane.cwd.as_ref().map(PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + let extra_env = super::env::normalize_launch_env(pane.env.clone()) + .map_err(|(_, message)| message.to_string())?; + let direction = match direction { + SplitDirection::Right => Direction::Horizontal, + SplitDirection::Down => Direction::Vertical, + }; + let command = layout_command(pane)?; + let result = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return Err("workspace not found".into()); + }; + if let Some(argv) = command.as_deref() { + ws.split_pane_argv_command_with_ratio( + target_pane_id, + direction, + ratio, + rows, + cols, + cwd, + argv, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + false, + ) + } else { + ws.split_pane_with_ratio( + target_pane_id, + direction, + ratio, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), + extra_env, + false, + ) + } + }; + let (_, new_pane) = result + .ok_or_else(|| "pane not found".to_string())? + .map_err(|err| err.to_string())?; + let new_pane_id = new_pane.pane_id; + self.attach_new_layout_pane(new_pane); + self.apply_layout_pane_label(ws_idx, new_pane_id, pane); + Ok(new_pane_id) + } + + fn attach_new_layout_pane(&mut self, new_pane: NewPane) { + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + } + + fn apply_layout_pane_label(&mut self, ws_idx: usize, pane_id: PaneId, pane: &LayoutPane) { + let Some(label) = pane + .label + .as_ref() + .map(|label| label.trim()) + .filter(|label| !label.is_empty()) + else { + return; + }; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { + return; + }; + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.set_manual_label(label.to_string()); + } + } + + fn rollback_layout_tab(&mut self, ws_idx: usize, root_pane: PaneId) { + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.iter().position(|tab| tab.root_pane == root_pane)) + else { + return; + }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + if self + .state + .workspaces + .get_mut(ws_idx) + .is_some_and(|ws| ws.close_tab(tab_idx)) + { + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + } + } +} + +fn first_layout_leaf(node: &LayoutNode) -> &LayoutPane { + match node { + LayoutNode::Pane { pane } => pane, + LayoutNode::Split { first, .. } => first_layout_leaf(first), + } +} + +fn layout_command(pane: &LayoutPane) -> Result<Option<Vec<String>>, String> { + match pane.command.as_ref() { + Some(command) if command.is_empty() => Err("pane command must not be empty".into()), + Some(command) => Ok(Some(command.clone())), + None => Ok(None), + } +} + +fn validate_layout_tree(root: &LayoutNode) -> Result<(), String> { + let mut stats = LayoutTreeStats { + panes: 0, + max_depth: 0, + }; + validate_layout_node(root, 1, &mut stats)?; + if stats.panes > MAX_LAYOUT_PANES { + return Err(format!( + "layout has {} panes; maximum is {}", + stats.panes, MAX_LAYOUT_PANES + )); + } + if stats.max_depth > MAX_LAYOUT_DEPTH { + return Err(format!( + "layout depth is {}; maximum is {}", + stats.max_depth, MAX_LAYOUT_DEPTH + )); + } + Ok(()) +} + +struct LayoutTreeStats { + panes: usize, + max_depth: usize, +} + +fn validate_layout_node( + node: &LayoutNode, + depth: usize, + stats: &mut LayoutTreeStats, +) -> Result<(), String> { + stats.max_depth = stats.max_depth.max(depth); + if depth > MAX_LAYOUT_DEPTH { + return Err(format!( + "layout depth is {}; maximum is {}", + depth, MAX_LAYOUT_DEPTH + )); + } + match node { + LayoutNode::Pane { pane } => { + stats.panes += 1; + if stats.panes > MAX_LAYOUT_PANES { + return Err(format!("layout has more than {} panes", MAX_LAYOUT_PANES)); + } + layout_command(pane)?; + super::env::normalize_launch_env(pane.env.clone()) + .map_err(|(_, message)| message.to_string())?; + Ok(()) + } + LayoutNode::Split { + first, + second, + ratio, + .. + } => { + if !ratio.is_finite() { + return Err("split ratio must be finite".into()); + } + validate_layout_node(first, depth + 1, stats)?; + validate_layout_node(second, depth + 1, stats) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + api::schema::{ErrorResponse, ResponseResult, SuccessResponse}, + config::Config, + workspace::Workspace, + }; + + fn app_with_workspace() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("layout")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + app + } + + #[test] + fn layout_export_returns_portable_tree() { + let mut app = app_with_workspace(); + let root = app.state.workspaces[0].tabs[0].root_pane; + let right = app.state.workspaces[0].test_split(Direction::Horizontal); + app.state.ensure_test_terminals(); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + app.state.workspaces[0].tabs[0] + .layout + .set_ratio_at(&[], 0.65); + let right_terminal_id = app.state.workspaces[0].tabs[0] + .terminal_id(right) + .cloned() + .unwrap(); + app.state + .terminals + .get_mut(&right_terminal_id) + .unwrap() + .set_manual_label("tests".into()); + + let response = app.handle_layout_export( + "req".into(), + LayoutExportParams { + tab_id: None, + pane_id: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutExport { layout } = success.result else { + panic!("expected layout export response"); + }; + assert_eq!(layout.workspace_id, app.public_workspace_id(0)); + assert_eq!(layout.focused_pane_id, app.public_pane_id(0, root).unwrap()); + let LayoutNode::Split { + direction, + ratio, + second, + .. + } = layout.root + else { + panic!("expected split layout root"); + }; + assert_eq!(direction, SplitDirection::Right); + assert!((ratio - 0.65).abs() < f32::EPSILON); + let LayoutNode::Pane { pane } = *second else { + panic!("expected second pane"); + }; + assert_eq!(pane.label.as_deref(), Some("tests")); + assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap())); + } + + #[tokio::test] + async fn layout_apply_replaces_tab_with_requested_tree() { + let mut app = app_with_workspace(); + let original_tab_id = app.public_tab_id(0, 0).unwrap(); + + let response = app.handle_layout_apply( + "req".into(), + LayoutApplyParams { + workspace_id: None, + tab_id: Some(original_tab_id), + tab_label: Some("dev".into()), + focus: true, + root: LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.7, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("tests".into()), + command: Some(vec!["sh".into(), "-c".into(), "true".into()]), + env: std::collections::HashMap::from([( + "HERDR_ROLE".into(), + "tests".into(), + )]), + ..Default::default() + }, + }), + }, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutApply { layout } = success.result else { + panic!("expected layout apply response"); + }; + assert_eq!(app.state.workspaces[0].tabs.len(), 1); + assert_eq!(app.state.workspaces[0].tabs[0].display_name(), "dev"); + let LayoutNode::Split { + direction, + ratio, + first, + second, + } = layout.root + else { + panic!("expected split layout root"); + }; + assert_eq!(direction, SplitDirection::Right); + assert!((ratio - 0.7).abs() < f32::EPSILON); + let LayoutNode::Pane { pane: first_pane } = *first else { + panic!("expected first pane"); + }; + let LayoutNode::Pane { pane: second_pane } = *second else { + panic!("expected second pane"); + }; + assert_eq!(first_pane.label.as_deref(), Some("editor")); + assert_eq!(second_pane.label.as_deref(), Some("tests")); + assert_eq!( + second_pane.command, + Some(vec!["sh".into(), "-c".into(), "true".into()]) + ); + } + + #[tokio::test] + async fn layout_apply_rejects_invalid_deep_leaf_without_creating_tab() { + let mut app = app_with_workspace(); + let original_tab_count = app.state.workspaces[0].tabs.len(); + + let response = app.handle_layout_apply( + "req".into(), + LayoutApplyParams { + workspace_id: Some(app.public_workspace_id(0)), + tab_id: None, + tab_label: Some("bad".into()), + focus: false, + root: LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.5, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + command: Some(Vec::new()), + ..Default::default() + }, + }), + }, + }, + ); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "invalid_layout"); + assert_eq!(app.state.workspaces[0].tabs.len(), original_tab_count); + } + + #[test] + fn layout_validation_rejects_too_many_panes() { + let mut root = LayoutNode::Pane { + pane: LayoutPane::default(), + }; + for _ in 0..MAX_LAYOUT_PANES { + root = LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.5, + first: Box::new(root), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane::default(), + }), + }; + } + + let err = validate_layout_tree(&root).unwrap_err(); + assert!(err.contains("maximum")); + } +} |
