use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use ratatui::layout::Direction; use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; use crate::pane::{PaneLaunchEnv, PaneState}; use crate::render_signal::RenderSignal; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; pub(crate) type DetachedPane = (PaneId, TerminalId); pub(crate) struct MovedPane { pub pane_id: PaneId, pub pane_state: PaneState, } pub struct NewPane { pub pane_id: PaneId, pub terminal: TerminalState, pub runtime: TerminalRuntime, } enum SplitCommand<'a> { Shell { command: &'a str, launch_env: &'a PaneLaunchEnv, }, Argv { argv: &'a [String], launch_env: &'a PaneLaunchEnv, }, } pub struct Tab { pub custom_name: Option, pub number: usize, /// Identity source for this tab's pane tree. pub root_pane: PaneId, pub layout: TileLayout, /// Pane viewport state — always present, testable without PTYs. pub panes: HashMap, #[cfg(test)] pub runtimes: HashMap, pub zoomed: bool, pub events: mpsc::Sender, pub(crate) render_notify: Arc, pub(crate) render_dirty: Arc, } impl Tab { // Tab construction threads pane runtime geometry, host context, and render hooks. #[allow(clippy::too_many_arguments)] pub fn new( number: usize, initial_cwd: PathBuf, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: crate::pane::PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, launch_env, events, render_notify, render_dirty, None, ) } // Command tab construction mirrors the shell tab runtime arguments. #[allow(clippy::too_many_arguments)] pub fn new_argv_command( number: usize, initial_cwd: PathBuf, rows: u16, cols: u16, argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, launch_env: &PaneLaunchEnv, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), launch_env, events, render_notify, render_dirty, Some(argv), ) } #[allow(clippy::too_many_arguments)] fn new_with_runtime( number: usize, initial_cwd: PathBuf, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: crate::pane::PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, argv: Option<&[String]>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (layout, root_id) = TileLayout::new(); let runtime = if let Some(argv) = argv { TerminalRuntime::spawn_argv_command( root_id, rows, cols, initial_cwd.clone(), argv, launch_env, crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, events.clone(), render_notify.clone(), render_dirty.clone(), )? } else { TerminalRuntime::spawn( root_id, rows, cols, initial_cwd.clone(), scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, launch_env, events.clone(), render_notify.clone(), render_dirty.clone(), )? }; let terminal_id = TerminalId::alloc(); let terminal = match argv { Some(argv) => { TerminalState::new(terminal_id.clone(), initial_cwd).with_launch_argv(argv.to_vec()) } None => TerminalState::new(terminal_id.clone(), initial_cwd), }; let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new(terminal_id)); Ok(( Self { custom_name: None, number, root_pane: root_id, layout, panes, #[cfg(test)] runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, }, terminal, runtime, )) } pub fn is_auto_named(&self) -> bool { self.custom_name.is_none() } pub fn set_custom_name(&mut self, name: String) { self.custom_name = Some(name); } pub fn split_focused_command( &mut self, direction: Direction, rows: u16, cols: u16, cwd: Option, command: &str, launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, ) -> std::io::Result { self.split_pane_with_runtime( self.layout.focused(), true, direction, None, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), launch_env, Some(SplitCommand::Shell { command, launch_env, }), ) } /// Split `target` with a shell pane. Focus moves to the new pane only when /// `focus_new_pane` is set; a spawn failure rolls the layout back without /// touching focus or its history. #[allow(clippy::too_many_arguments)] pub(crate) fn split_pane_shell( &mut self, target: PaneId, focus_new_pane: bool, direction: Direction, ratio: Option, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: crate::pane::PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, ) -> std::io::Result { self.split_pane_with_runtime( target, focus_new_pane, direction, ratio, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, launch_env, None, ) } /// Split `target` with an argv-command pane. Same focus contract as /// `split_pane_shell`. #[allow(clippy::too_many_arguments)] pub(crate) fn split_pane_argv( &mut self, target: PaneId, focus_new_pane: bool, direction: Direction, ratio: Option, rows: u16, cols: u16, cwd: Option, argv: &[String], launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, ) -> std::io::Result { self.split_pane_with_runtime( target, focus_new_pane, direction, ratio, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), launch_env, Some(SplitCommand::Argv { argv, launch_env }), ) } // Split construction threads geometry, host context, launch policy, and command state. #[allow(clippy::too_many_arguments)] fn split_pane_with_runtime( &mut self, target: PaneId, focus_new_pane: bool, direction: Direction, ratio: Option, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: crate::pane::PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, command: Option>, ) -> std::io::Result { let Some(new_id) = self .layout .split_pane(target, direction, ratio.unwrap_or(0.5)) else { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, "split target pane is not in the layout", )); }; let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let launch_argv = if let Some(SplitCommand::Argv { argv, .. }) = &command { Some((*argv).to_vec()) } else { None }; let runtime = match command { Some(SplitCommand::Shell { command, launch_env, }) => TerminalRuntime::spawn_shell_command( new_id, rows, cols, actual_cwd.clone(), command, launch_env, crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), ), Some(SplitCommand::Argv { argv, launch_env }) => TerminalRuntime::spawn_argv_command( new_id, rows, cols, actual_cwd.clone(), argv, launch_env, crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), ), None => TerminalRuntime::spawn( new_id, rows, cols, actual_cwd.clone(), scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, launch_env, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), ), }; let runtime = match runtime { Ok(runtime) => runtime, Err(err) => { self.layout.close_pane(new_id); return Err(err); } }; let terminal_id = TerminalId::alloc(); let terminal = match launch_argv { Some(argv) => { TerminalState::new(terminal_id.clone(), actual_cwd).with_launch_argv(argv) } None => TerminalState::new(terminal_id.clone(), actual_cwd), }; if focus_new_pane { self.layout.focus_pane(new_id); } self.panes.insert(new_id, PaneState::new(terminal_id)); self.zoomed = false; Ok(NewPane { pane_id: new_id, terminal, runtime, }) } #[cfg(test)] pub fn close_focused(&mut self) -> Option { let pane_id = self.layout.focused(); self.detach_pane(pane_id) } pub fn close_pane(&mut self, pane_id: PaneId) -> Option { self.detach_pane(pane_id) } pub fn remove_pane(&mut self, pane_id: PaneId) -> Option { self.detach_pane(pane_id) } pub(crate) fn from_existing_pane( number: usize, custom_name: Option, moved: MovedPane, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> Self { let mut panes = HashMap::new(); let pane_id = moved.pane_id; panes.insert(pane_id, moved.pane_state); Self { custom_name, number, root_pane: pane_id, layout: TileLayout::from_saved(Node::Pane(pane_id), pane_id), panes, #[cfg(test)] runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, } } pub(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option { if !self.panes.contains_key(&pane_id) { return None; } if self.layout.pane_count() > 1 { let next_root = self.promoted_root_if_needed(pane_id); self.layout.close_pane(pane_id); if let Some(next_root) = next_root { self.root_pane = next_root; } } let pane_state = self.panes.remove(&pane_id)?; self.zoomed = false; Some(MovedPane { pane_id, pane_state, }) } pub(crate) fn insert_existing_pane( &mut self, target_pane_id: PaneId, moved: MovedPane, direction: Direction, ratio: f32, focus: bool, ) -> Result { if !self .layout .insert_pane_near(target_pane_id, moved.pane_id, direction, ratio, focus) { return Err(moved); } let pane_id = moved.pane_id; self.panes.insert(pane_id, moved.pane_state); self.zoomed = false; Ok(pane_id) } fn detach_pane(&mut self, pane_id: PaneId) -> Option { if self.layout.pane_count() <= 1 { return None; } let next_root = self.promoted_root_if_needed(pane_id); self.layout.close_pane(pane_id); let pane = self.panes.remove(&pane_id)?; let terminal_id = pane.attached_terminal_id; self.zoomed = false; if let Some(next_root) = next_root { self.root_pane = next_root; } Some((pane_id, terminal_id)) } fn promoted_root_if_needed(&self, closing: PaneId) -> Option { if self.root_pane != closing { return None; } self.layout.pane_ids().into_iter().find(|id| *id != closing) } pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { self.panes .get(&pane_id) .map(|pane| &pane.attached_terminal_id) } pub fn cwd_for_pane( &self, pane_id: PaneId, terminals: &HashMap, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option { let terminal_id = self.terminal_id(pane_id)?; terminal_runtimes .get(terminal_id) .and_then(|rt| rt.cwd()) .or_else(|| { terminals .get(terminal_id) .map(|terminal| terminal.cwd.clone()) }) } pub fn foreground_cwd_for_pane( &self, pane_id: PaneId, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option { let terminal_id = self.terminal_id(pane_id)?; terminal_runtimes .get(terminal_id) .and_then(|rt| rt.foreground_cwd()) } }