diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-15 03:58:07 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-15 04:02:14 +0300 |
| commit | 2c7c8beb079c5fd58a2e53efd2058df050ad9f55 (patch) | |
| tree | 6756f671dc728d915b1d6ebececa8cf7f70b66fb /src/app/popup.rs | |
| parent | 88370e1516182ab519d22a12fa82d5780a40fee6 (diff) | |
feat: add floating popup panes
refs #1125
Diffstat (limited to 'src/app/popup.rs')
| -rw-r--r-- | src/app/popup.rs | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 00000000..3aeef35c --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,309 @@ +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<PopupSize>, + pub height: Option<PopupSize>, +} + +impl App { + pub(crate) fn popup_runtime(&self) -> Option<&TerminalRuntime> { + let terminal_id = &self.state.popup_pane.as_ref()?.terminal_id; + self.terminal_runtimes.get(terminal_id) + } + + 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); + if let Some(runtime) = self.terminal_runtimes.remove(&popup.terminal_id) { + runtime.shutdown(); + } + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + self.render_dirty + .store(true, std::sync::atomic::Ordering::Release); + self.render_notify.notify_one(); + true + } + + pub(crate) fn try_route_paste_to_popup(&mut self, text: &str) -> bool { + if self.state.popup_pane.is_none() { + return false; + } + let Some(runtime) = self.popup_runtime() else { + self.close_popup_pane(); + return true; + }; + let _ = runtime.try_send_paste(text.to_owned()); + true + } + + pub(crate) fn spawn_popup_shell_command( + &mut self, + command: &str, + cwd: Option<PathBuf>, + 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.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<PathBuf>, + 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.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .map(|runtime| (runtime, Some(argv.to_vec()))) + }, + ) + } + + fn spawn_popup_command<F>( + &mut self, + cwd: Option<PathBuf>, + 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<Vec<String>>)>, + { + 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(), + true, + 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"); + } +} |
