aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 03:58:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 04:02:14 +0300
commit2c7c8beb079c5fd58a2e53efd2058df050ad9f55 (patch)
tree6756f671dc728d915b1d6ebececa8cf7f70b66fb /src/app/api/plugins
parent88370e1516182ab519d22a12fa82d5780a40fee6 (diff)
feat: add floating popup panes
refs #1125
Diffstat (limited to 'src/app/api/plugins')
-rw-r--r--src/app/api/plugins/manifest.rs15
-rw-r--r--src/app/api/plugins/mod.rs256
-rw-r--r--src/app/api/plugins/panes.rs33
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(&copy_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,