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/api/plugins | |
| parent | 88370e1516182ab519d22a12fa82d5780a40fee6 (diff) | |
feat: add floating popup panes
refs #1125
Diffstat (limited to 'src/app/api/plugins')
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 15 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 256 | ||||
| -rw-r--r-- | src/app/api/plugins/panes.rs | 33 |
3 files changed, 301 insertions, 3 deletions
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index 92ab4754..c2f17704 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -3,6 +3,7 @@ use crate::api::schema::{ PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, PluginSourceInfo, PluginSourceKind, }; +use crate::popup_size::PopupSize; const PLUGIN_ID_MAX_CHARS: usize = 120; const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; @@ -68,6 +69,10 @@ struct RawPluginManifestPane { platforms: Option<Vec<RawPlatform>>, #[serde(default)] placement: PluginPanePlacement, + #[serde(default)] + width: Option<PopupSize>, + #[serde(default)] + height: Option<PopupSize>, command: Vec<String>, } @@ -392,12 +397,22 @@ fn normalize_manifest_pane( .filter(|description| !description.is_empty()); let platforms = normalize_platforms(pane.platforms)?; let command = normalize_command(pane.command)?; + if pane.placement != PluginPanePlacement::Popup + && (pane.width.is_some() || pane.height.is_some()) + { + return Err(( + "invalid_plugin_pane_size", + "pane width and height are only supported when placement is popup".to_string(), + )); + } Ok(PluginManifestPane { id, title, description, platforms, placement: pane.placement, + width: pane.width, + height: pane.height, command, }) } diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b7f90df9..164d566e 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -349,8 +349,25 @@ impl App { return encode_error(id, code, message); } let placement = params.placement.unwrap_or(pane.placement); + if placement != PluginPanePlacement::Popup + && (params.width.is_some() || params.height.is_some()) + { + return encode_error( + id, + "invalid_params", + "width and height are only supported when placement is popup", + ); + } + if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal + { + return encode_error( + id, + "ui_busy", + "popup panes can only open from the normal workspace view", + ); + } match placement { - PluginPanePlacement::Overlay => { + PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { if params.workspace_id.is_some() || params.target_pane_id.is_some() || params.direction.is_some() @@ -358,7 +375,7 @@ impl App { return encode_error( id, "invalid_params", - "overlay plugin panes target the active pane", + "overlay and popup plugin panes target the active pane", ); } } @@ -386,6 +403,7 @@ impl App { PluginPanePlacement::Overlay => { self.open_plugin_overlay_pane(id, params, &plugin, pane) } + PluginPanePlacement::Popup => self.open_plugin_popup_pane(id, params, &plugin, pane), PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { self.open_plugin_split_pane(id, params, &plugin, pane, placement) } @@ -659,7 +677,7 @@ fn manifest_actions( mod tests { use super::*; use crate::api::schema::{ - Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, + Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -983,6 +1001,24 @@ platforms = ["linux", "macos", "windows"] "#, "plugin_requires_newer_herdr", ), + ( + "plugin-non-popup-size", + r#" +id = "example.non-popup-size" +name = "Non Popup Size" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[panes]] +id = "board" +title = "Board" +placement = "split" +width = "80%" +command = ["echo", "board"] +"#, + "invalid_plugin_pane_size", + ), ]; for (name, manifest, expected_code) in cases { @@ -1139,6 +1175,8 @@ command = ["echo", "b"] plugin_id: "example.missing".into(), entrypoint: "ui".into(), placement: Some(PluginPanePlacement::Split), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1151,6 +1189,98 @@ command = ["echo", "b"] assert_eq!(value["error"]["code"], "plugin_not_found"); } + #[test] + fn plugin_pane_open_rejects_popup_size_for_non_popup_placement() { + let mut app = test_app(); + let root = unique_temp_path("plugin-pane-non-popup-size-param"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let response = app.handle_api_request(Request { + id: "pane-open-size".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Split), + width: Some(crate::popup_size::PopupSize::Percent(80)), + height: None, + workspace_id: None, + target_pane_id: None, + direction: Some(crate::api::schema::SplitDirection::Right), + cwd: None, + focus: false, + env: std::collections::HashMap::new(), + }), + }); + + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "invalid_params"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_pane_open_popup_preserves_existing_ui_modes() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let root_pane = app.state.workspaces[0].tabs[0].root_pane; + let root = unique_temp_path("plugin-popup-ui-busy"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let open_popup = |app: &mut App, id: &str| { + app.handle_api_request(Request { + id: id.into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Popup), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }) + }; + + app.state.mode = crate::app::Mode::Settings; + app.state.settings.original_theme = Some("settings-theme".into()); + let settings_response = open_popup(&mut app, "settings-popup"); + let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); + assert_eq!(settings_error["error"]["code"], "ui_busy"); + assert_eq!(app.state.mode, crate::app::Mode::Settings); + assert_eq!( + app.state.settings.original_theme.as_deref(), + Some("settings-theme") + ); + assert!(app.state.popup_pane.is_none()); + + let copy_mode = crate::app::state::CopyModeState { + pane_id: root_pane, + cursor_row: 2, + cursor_col: 3, + entry_offset_from_bottom: 4, + selection: None, + search: crate::app::state::CopyModeSearchState::default(), + }; + app.state.mode = crate::app::Mode::Copy; + app.state.copy_mode = Some(copy_mode.clone()); + let copy_response = open_popup(&mut app, "copy-popup"); + let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); + assert_eq!(copy_error["error"]["code"], "ui_busy"); + assert_eq!(app.state.mode, crate::app::Mode::Copy); + assert_eq!(app.state.copy_mode, Some(copy_mode)); + assert!(app.state.popup_pane.is_none()); + + let _ = std::fs::remove_dir_all(root); + } + #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1200,6 +1330,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \ plugin_id: "example.pane".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1305,6 +1437,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL plugin_id: "example.path-env".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1406,6 +1540,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.tab".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1487,6 +1623,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.split".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Zoomed), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: Some(crate::api::schema::SplitDirection::Right), @@ -1564,6 +1702,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.overlay".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1598,6 +1738,114 @@ command = ["sh", "-c", "sleep 1"] let _ = std::fs::remove_dir_all(root); } + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_popup_is_layout_neutral() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-popup")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + let root_pane = app.state.workspaces[0].tabs[0].root_pane; + let root_public = app.public_pane_id(0, root_pane).unwrap(); + + let root = unique_temp_path("plugin-pane-popup"); + let env_capture = root.join("popup-env.txt"); + let manifest = format!( + r#" +id = "example.popup" +name = "Popup Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Popup" +placement = "popup" +width = "80%" +height = "40%" +command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] +"#, + env_capture.display() + ); + write_manifest_content(&root, &manifest); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open-popup".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.popup".into(), + entrypoint: "board".into(), + placement: None, + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + assert_eq!(response_result(&open), ResponseResult::Ok {}); + assert_eq!( + read_capture_when_ready(&env_capture, || { + app.drain_internal_events(); + }), + "unset" + ); + + let opened_pane_id = app.state.popup_pane.as_ref().unwrap().pane_id; + assert!(!app.state.plugin_panes.contains_key(&opened_pane_id)); + app.state.assert_invariants_for_test(); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); + let (outer, inner) = crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area) + .expect("popup rects"); + assert_eq!((outer.width, outer.height), (80, 12)); + assert_eq!((inner.width, inner.height), (77, 10)); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + + let pane_list = app.handle_api_request(Request { + id: "pane-list-popup".into(), + method: Method::PaneList(PaneListParams { + workspace_id: Some(app.public_workspace_id(0)), + }), + }); + let ResponseResult::PaneList { panes } = response_result(&pane_list) else { + panic!("expected pane list response: {pane_list}"); + }; + assert_eq!(panes.len(), 1); + assert_eq!(panes[0].pane_id, root_public); + assert!(panes[0].focused); + assert_eq!( + app.current_plugin_context("popup-open").focused_pane_id, + Some(root_public) + ); + assert!(event_hub.events_after(0).is_empty()); + + app.handle_internal_event(crate::events::AppEvent::PaneDied { + pane_id: opened_pane_id, + }); + assert!(app.state.popup_pane.is_none()); + assert!(event_hub.events_after(0).is_empty()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + #[test] fn manifest_action_list_and_invoke_with_context() { let mut app = test_app(); @@ -1729,6 +1977,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.worktree-bootstrap".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index b2988998..74a5df23 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -8,6 +8,39 @@ use crate::api::schema::{ use crate::app::App; impl App { + pub(super) fn open_plugin_popup_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let context = self.current_plugin_context("plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let width = params.width.or(pane.width); + let height = params.height.or(pane.height); + if let Err(err) = self.spawn_popup_argv_command( + &pane.command, + cwd, + extra_env, + crate::app::popup::PopupGeometry { width, height }, + ) { + return encode_error(id, "plugin_pane_open_failed", err.to_string()); + } + let Some(popup) = self.state.popup_pane.as_ref() else { + return encode_error(id, "plugin_pane_open_failed", "plugin popup disappeared"); + }; + if let Some(terminal) = self.state.terminals.get_mut(&popup.terminal_id) { + terminal.set_manual_label(pane.title); + } + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn open_plugin_overlay_pane( &mut self, id: String, |
