use std::path::PathBuf; use crate::app::{App, Mode}; use crate::layout::PaneId; use crate::pane::PaneLaunchEnv; use crate::popup_size::{resolve_popup_geometry, PopupSize}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct PopupGeometry { pub width: Option, pub height: Option, } impl App { pub(crate) fn close_popup_pane(&mut self) -> bool { let Some(popup) = self.state.popup_pane.take() else { return false; }; self.state .direct_attach_resize_locks .remove(&popup.terminal_id); self.state.terminals.remove(&popup.terminal_id); self.shutdown_terminal_runtime(popup.terminal_id); self.state.mode = if self.state.active.is_some() { Mode::Terminal } else { Mode::Navigate }; self.render_dirty.request_generic(); self.render_notify.notify_one(); true } pub(crate) fn spawn_popup_shell_command( &mut self, command: &str, cwd: Option, extra_env: Vec<(String, String)>, geometry: PopupGeometry, ) -> std::io::Result<()> { self.spawn_popup_command( cwd, extra_env, geometry, |pane_id, rows, cols, cwd, launch_env, app| { TerminalRuntime::spawn_shell_command( pane_id, rows, cols, cwd, command, launch_env, crate::pane::AgentDetection::Disabled, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, app.state.host_terminal_appearance, app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), ) .map(|runtime| (runtime, None)) }, ) } pub(crate) fn spawn_popup_argv_command( &mut self, argv: &[String], cwd: Option, extra_env: Vec<(String, String)>, geometry: PopupGeometry, ) -> std::io::Result<()> { self.spawn_popup_command( cwd, extra_env, geometry, |pane_id, rows, cols, cwd, launch_env, app| { TerminalRuntime::spawn_argv_command( pane_id, rows, cols, cwd, argv, launch_env, crate::pane::AgentDetection::Disabled, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, app.state.host_terminal_appearance, app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), ) .map(|runtime| (runtime, Some(argv.to_vec()))) }, ) } fn spawn_popup_command( &mut self, cwd: Option, extra_env: Vec<(String, String)>, geometry: PopupGeometry, spawn: F, ) -> std::io::Result<()> where F: FnOnce( PaneId, u16, u16, PathBuf, &PaneLaunchEnv, &mut App, ) -> std::io::Result<(TerminalRuntime, Option>)>, { if self.state.popup_pane.is_some() { return Err(std::io::Error::other("popup already open")); } let Some(ws_idx) = self.state.active else { return Err(std::io::Error::other("no active workspace")); }; let ws = self .state .workspaces .get(ws_idx) .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; let active_tab = ws .active_tab() .ok_or_else(|| std::io::Error::other("active tab disappeared"))?; let focused_pane = ws .focused_pane_id() .ok_or_else(|| std::io::Error::other("active tab has no focused pane"))?; let cwd = cwd.or_else(|| { active_tab.cwd_for_pane(focused_pane, &self.state.terminals, &self.terminal_runtimes) }); let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let pane_id = PaneId::alloc(); let terminal_id = TerminalId::alloc(); let launch_env = PaneLaunchEnv::from_extra(extra_env).without_pane_identity(); let terminal_area = if self.state.view.terminal_area.width >= 4 && self.state.view.terminal_area.height >= 4 { self.state.view.terminal_area } else { let (estimated_rows, estimated_cols) = self.state.estimate_pane_size(); ratatui::layout::Rect::new(0, 0, estimated_cols, estimated_rows) }; let Some(resolved_geometry) = resolve_popup_geometry(geometry.width, geometry.height, terminal_area) else { return Err(std::io::Error::other("terminal area too small for popup")); }; let rows = resolved_geometry.inner.height; let cols = resolved_geometry.inner.width; let (runtime, launch_argv) = spawn(pane_id, rows, cols, cwd.clone(), &launch_env, self)?; let terminal = match launch_argv { Some(argv) => TerminalState::new(terminal_id.clone(), cwd).with_launch_argv(argv), None => TerminalState::new(terminal_id.clone(), cwd), }; self.terminal_runtimes.insert(terminal_id.clone(), runtime); self.state.terminals.insert(terminal_id.clone(), terminal); self.state.popup_pane = Some(crate::app::state::PopupPaneState { pane_id, terminal_id, width: geometry.width, height: geometry.height, }); self.state.mode = Mode::Terminal; Ok(()) } } #[cfg(test)] impl App { pub(crate) fn install_test_popup_runtime( &mut self, runtime: TerminalRuntime, ) -> (PaneId, TerminalId) { let pane_id = PaneId::alloc(); let terminal_id = TerminalId::alloc(); self.terminal_runtimes.insert(terminal_id.clone(), runtime); self.state.terminals.insert( terminal_id.clone(), TerminalState::new(terminal_id.clone(), PathBuf::from("/popup")), ); self.state.popup_pane = Some(crate::app::state::PopupPaneState { pane_id, terminal_id: terminal_id.clone(), width: None, height: None, }); (pane_id, terminal_id) } } #[cfg(test)] mod tests { use super::*; fn app_with_popup() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("popup")]; app.state.active = Some(0); app.state.selected = 0; let terminal_id = TerminalId::alloc(); app.state.terminals.insert( terminal_id.clone(), TerminalState::new(terminal_id.clone(), PathBuf::from("/popup")), ); app.state.popup_pane = Some(crate::app::state::PopupPaneState { pane_id: PaneId::alloc(), terminal_id, width: None, height: None, }); app } #[test] fn close_popup_uses_terminal_mode_with_active_workspace() { let mut app = app_with_popup(); app.state.mode = Mode::Navigate; assert!(app.close_popup_pane()); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn close_popup_uses_navigate_mode_without_active_workspace() { let mut app = app_with_popup(); app.state.workspaces.clear(); app.state.active = None; app.state.mode = Mode::Navigate; assert!(app.close_popup_pane()); assert_eq!(app.state.mode, Mode::Navigate); } #[test] fn close_popup_clears_direct_attach_resize_lock() { let mut app = app_with_popup(); let terminal_id = app.state.popup_pane.as_ref().unwrap().terminal_id.clone(); app.state .direct_attach_resize_locks .insert(terminal_id.clone()); assert!(app.close_popup_pane()); assert!(!app.state.direct_attach_resize_locks.contains(&terminal_id)); } #[test] fn popup_survives_background_workspace_removal() { let mut app = app_with_popup(); app.state.workspaces.clear(); app.state.active = None; app.state.assert_invariants_for_test(); assert!(app.state.popup_pane.is_some()); } #[test] fn popup_close_api_closes_only_active_popup() { let mut app = app_with_popup(); let close = || crate::api::schema::Request { id: "close-popup".into(), method: crate::api::schema::Method::PopupClose( crate::api::schema::EmptyParams::default(), ), }; let response = app.handle_api_request(close()); let response: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(response.result, crate::api::schema::ResponseResult::Ok {}); let response = app.handle_api_request(close()); let response: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(response.error.code, "popup_not_open"); } }