aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins/panes.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /src/app/api/plugins/panes.rs
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'src/app/api/plugins/panes.rs')
-rw-r--r--src/app/api/plugins/panes.rs284
1 files changed, 284 insertions, 0 deletions
diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs
new file mode 100644
index 00000000..22173cbf
--- /dev/null
+++ b/src/app/api/plugins/panes.rs
@@ -0,0 +1,284 @@
+use ratatui::layout::Direction;
+
+use super::super::responses::{encode_error, encode_success};
+use crate::api::schema::{
+ InstalledPluginInfo, PluginInvocationContext, PluginManifestPane, PluginPaneInfo,
+ PluginPaneOpenParams, PluginPanePlacement, ResponseResult,
+};
+use crate::app::App;
+
+impl App {
+ pub(super) fn open_plugin_overlay_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 (ws_idx, new_pane) =
+ match self.spawn_overlay_argv_command(&pane.command, cwd, extra_env, Vec::new()) {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ };
+ self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane)
+ }
+
+ pub(super) fn open_plugin_split_pane(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ placement: PluginPanePlacement,
+ ) -> String {
+ let target_pane_id = params
+ .target_pane_id
+ .clone()
+ .or_else(|| self.current_public_pane_id());
+ let Some(target_pane_id) = target_pane_id else {
+ return encode_error(id, "no_active_pane", "no active pane");
+ };
+ let Some((ws_idx, target_pane)) = self.parse_pane_id(&target_pane_id) else {
+ return encode_error(
+ id,
+ "pane_not_found",
+ format!("pane {target_pane_id} not found"),
+ );
+ };
+ let context = self.plugin_context_for_pane(ws_idx, target_pane, "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 direction = match params
+ .direction
+ .unwrap_or(crate::api::schema::SplitDirection::Right)
+ {
+ crate::api::schema::SplitDirection::Right => Direction::Horizontal,
+ crate::api::schema::SplitDirection::Down => Direction::Vertical,
+ };
+ let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd));
+ let (rows, cols) = self.state.estimate_pane_size();
+ let previous_focus = self.state.current_pane_focus_target();
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ let result = ws.split_pane_argv_command(
+ target_pane,
+ direction,
+ rows.max(4),
+ cols.max(10),
+ cwd,
+ &pane.command,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ params.focus || placement == PluginPanePlacement::Zoomed,
+ );
+ let (tab_idx, new_pane) = match result {
+ Some(Ok(result)) => result,
+ Some(Err(err)) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ None => {
+ return encode_error(
+ id,
+ "pane_not_found",
+ format!("pane {target_pane_id} not found"),
+ )
+ }
+ };
+ if params.focus || placement == PluginPanePlacement::Zoomed {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state
+ .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id);
+ self.state.mode = crate::app::Mode::Terminal;
+ }
+ if placement == PluginPanePlacement::Zoomed {
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ tab.zoomed = true;
+ }
+ }
+ self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane)
+ }
+
+ pub(super) fn open_plugin_tab(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ ) -> String {
+ let ws_idx = match params.workspace_id.as_deref() {
+ Some(workspace_id) => match self.parse_workspace_id(workspace_id) {
+ Some(ws_idx) => ws_idx,
+ None => return encode_error(id, "workspace_not_found", "workspace not found"),
+ },
+ None => match self.state.active {
+ Some(ws_idx) => ws_idx,
+ None => return encode_error(id, "no_active_workspace", "no active workspace"),
+ },
+ };
+ let cwd = self.plugin_pane_cwd(plugin, params.cwd);
+ let context = self.plugin_context_for_workspace(ws_idx, "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 (rows, cols) = self.state.estimate_pane_size();
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command(
+ rows.max(4),
+ cols.max(10),
+ cwd,
+ &pane.command,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ ) {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ };
+ let pane_id = ws.tabs[tab_idx].root_pane;
+ if params.focus {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = crate::app::Mode::Terminal;
+ }
+ let new_pane = crate::workspace::NewPane {
+ pane_id,
+ terminal,
+ runtime,
+ };
+ self.finish_plugin_pane_open(
+ id,
+ ws_idx,
+ Some(tab_idx),
+ new_pane,
+ plugin.plugin_id.clone(),
+ pane,
+ )
+ }
+
+ fn plugin_pane_launch_env(
+ &self,
+ plugin: &InstalledPluginInfo,
+ entrypoint: &str,
+ env: std::collections::HashMap<String, String>,
+ context: &PluginInvocationContext,
+ ) -> Result<Vec<(String, String)>, (String, String)> {
+ let mut env = super::super::env::normalize_launch_env(env)?;
+ let context_json = serde_json::to_string(&context)
+ .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?;
+ env.retain(|(key, _)| !plugin_pane_protected_env_key(key));
+ env.extend(super::env::plugin_path_env(plugin));
+ env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()));
+ env.push((
+ "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(),
+ entrypoint.to_string(),
+ ));
+ env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json));
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+ Ok(env)
+ }
+
+ fn finish_plugin_pane_open(
+ &mut self,
+ id: String,
+ ws_idx: usize,
+ created_tab_idx: Option<usize>,
+ new_pane: crate::workspace::NewPane,
+ plugin_id: String,
+ pane_manifest: PluginManifestPane,
+ ) -> String {
+ let entrypoint = pane_manifest.id.clone();
+ let mut terminal = new_pane.terminal;
+ terminal.set_manual_label(pane_manifest.title.clone());
+ let terminal_id = terminal.id.clone();
+ self.terminal_runtimes
+ .insert(terminal_id.clone(), new_pane.runtime);
+ self.state
+ .remove_alias_shadowed_by_new_pane(new_pane.pane_id);
+ self.state.terminals.insert(terminal_id, terminal);
+ self.state.plugin_panes.insert(
+ new_pane.pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: plugin_id.clone(),
+ entrypoint: entrypoint.clone(),
+ },
+ );
+ if let Some(tab_idx) = created_tab_idx {
+ if let Some(tab) = self.tab_info(ws_idx, tab_idx) {
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabCreated,
+ data: crate::api::schema::EventData::TabCreated { tab },
+ });
+ }
+ }
+ self.schedule_session_save();
+ let Some(pane) = self.pane_info(ws_idx, new_pane.pane_id) else {
+ return encode_error(id, "plugin_pane_open_failed", "plugin pane disappeared");
+ };
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneCreated,
+ data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() },
+ });
+ encode_success(
+ id,
+ ResponseResult::PluginPaneOpened {
+ plugin_pane: PluginPaneInfo {
+ plugin_id,
+ entrypoint,
+ pane,
+ },
+ },
+ )
+ }
+
+ fn plugin_pane_cwd(
+ &self,
+ plugin: &InstalledPluginInfo,
+ override_cwd: Option<String>,
+ ) -> std::path::PathBuf {
+ override_cwd
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(&plugin.plugin_root))
+ }
+
+ fn current_public_pane_id(&self) -> Option<String> {
+ let ws_idx = self.state.active?;
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ self.public_pane_id(ws_idx, pane_id)
+ }
+}
+
+fn plugin_pane_protected_env_key(key: &str) -> bool {
+ matches!(
+ key,
+ "HERDR_PLUGIN_ID"
+ | "HERDR_PLUGIN_ROOT"
+ | "HERDR_PLUGIN_CONFIG_DIR"
+ | "HERDR_PLUGIN_STATE_DIR"
+ | "HERDR_PLUGIN_ENTRYPOINT_ID"
+ | "HERDR_PLUGIN_CONTEXT_JSON"
+ | "HERDR_BIN_PATH"
+ )
+}