aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/agents.rs6
-rw-r--r--src/app/api/env.rs61
-rw-r--r--src/app/api/layouts.rs758
-rw-r--r--src/app/api/panes.rs386
-rw-r--r--src/app/api/plugins/context.rs379
-rw-r--r--src/app/api/plugins/env.rs19
-rw-r--r--src/app/api/plugins/manifest.rs539
-rw-r--r--src/app/api/plugins/mod.rs2704
-rw-r--r--src/app/api/plugins/panes.rs284
-rw-r--r--src/app/api/plugins/runtime.rs271
-rw-r--r--src/app/api/tabs.rs16
-rw-r--r--src/app/api/workspaces.rs59
-rw-r--r--src/app/api/worktrees.rs114
13 files changed, 5552 insertions, 44 deletions
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index 471b3052..7293823f 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -46,7 +46,11 @@ impl App {
}
pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String {
- let (agent, argv) = match self.start_agent(params) {
+ let extra_env = match super::env::normalize_launch_env(params.env.clone()) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let (agent, argv) = match self.start_agent(params, extra_env) {
Ok(started) => started,
Err(err) => return encode_error_body(id, self.agent_start_error_body(err)),
};
diff --git a/src/app/api/env.rs b/src/app/api/env.rs
new file mode 100644
index 00000000..db421f63
--- /dev/null
+++ b/src/app/api/env.rs
@@ -0,0 +1,61 @@
+use std::collections::HashMap;
+
+pub(super) fn normalize_launch_env(
+ env: HashMap<String, String>,
+) -> Result<Vec<(String, String)>, (String, String)> {
+ let mut normalized = Vec::with_capacity(env.len());
+ for (key, value) in env {
+ if key.is_empty() {
+ return Err(("invalid_env".into(), "env key must not be empty".into()));
+ }
+ if key.contains('=') {
+ return Err((
+ "invalid_env".into(),
+ format!("env key {key} must not contain '='"),
+ ));
+ }
+ if key.contains('\0') {
+ return Err((
+ "invalid_env".into(),
+ "env key must not contain NUL bytes".into(),
+ ));
+ }
+ if value.contains('\0') {
+ return Err((
+ "invalid_env".into(),
+ format!("env value for {key} must not contain NUL bytes"),
+ ));
+ }
+ normalized.push((key, value));
+ }
+ normalized.sort_by(|left, right| left.0.cmp(&right.0));
+ Ok(normalized)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalize_launch_env_sorts_entries() {
+ let env = HashMap::from([
+ ("ZED".to_string(), "last".to_string()),
+ ("ALPHA".to_string(), "first".to_string()),
+ ]);
+
+ assert_eq!(
+ normalize_launch_env(env).unwrap(),
+ vec![
+ ("ALPHA".to_string(), "first".to_string()),
+ ("ZED".to_string(), "last".to_string()),
+ ]
+ );
+ }
+
+ #[test]
+ fn normalize_launch_env_rejects_invalid_keys() {
+ let env = HashMap::from([("BAD=KEY".to_string(), "value".to_string())]);
+
+ assert_eq!(normalize_launch_env(env).unwrap_err().0, "invalid_env");
+ }
+}
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs
new file mode 100644
index 00000000..e397ccf8
--- /dev/null
+++ b/src/app/api/layouts.rs
@@ -0,0 +1,758 @@
+use std::path::PathBuf;
+
+use ratatui::layout::Direction;
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, LayoutApplyParams, LayoutDescription, LayoutExportParams,
+ LayoutNode, LayoutPane, ResponseResult, SplitDirection,
+};
+use crate::app::{App, Mode};
+use crate::layout::{Node, PaneId};
+use crate::workspace::NewPane;
+
+use super::responses::{encode_error, encode_success};
+
+const MAX_LAYOUT_PANES: usize = 24;
+const MAX_LAYOUT_DEPTH: usize = 16;
+
+impl App {
+ pub(super) fn handle_layout_export(
+ &mut self,
+ id: String,
+ params: LayoutExportParams,
+ ) -> String {
+ let Some((ws_idx, tab_idx)) = self.resolve_layout_export_target(&params) else {
+ return encode_error(id, "layout_not_found", "layout target not found");
+ };
+ let Some(layout) = self.layout_description(ws_idx, tab_idx) else {
+ return encode_error(id, "layout_not_found", "layout unavailable");
+ };
+
+ encode_success(id, ResponseResult::LayoutExport { layout })
+ }
+
+ pub(super) fn handle_layout_apply(&mut self, id: String, params: LayoutApplyParams) -> String {
+ let replace_target = match params.tab_id.as_deref() {
+ Some(tab_id) => match self.parse_tab_id(tab_id) {
+ Some(target) => Some(target),
+ None => {
+ return encode_error(id, "tab_not_found", format!("tab {tab_id} not found"))
+ }
+ },
+ None => None,
+ };
+ if replace_target.is_some() && params.workspace_id.is_some() {
+ return encode_error(
+ id,
+ "invalid_target",
+ "use either tab_id or workspace_id, not both",
+ );
+ }
+
+ let ws_idx = if let Some((ws_idx, _)) = replace_target {
+ ws_idx
+ } else if let Some(workspace_id) = params.workspace_id.as_deref() {
+ let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
+ return encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ );
+ };
+ ws_idx
+ } else if let Some(active) = self.state.active {
+ active
+ } else {
+ return encode_error(id, "workspace_not_found", "no active workspace");
+ };
+ if let Err(message) = validate_layout_tree(&params.root) {
+ return encode_error(id, "invalid_layout", message);
+ }
+
+ let replacement_label = params.tab_label.clone().or_else(|| {
+ let (_, tab_idx) = replace_target?;
+ self.state
+ .workspaces
+ .get(ws_idx)?
+ .tabs
+ .get(tab_idx)?
+ .custom_name
+ .clone()
+ });
+ let replace_was_active = replace_target.is_some_and(|(target_ws, target_tab)| {
+ self.state.active == Some(target_ws)
+ && self
+ .state
+ .workspaces
+ .get(target_ws)
+ .is_some_and(|ws| ws.active_tab_index() == target_tab)
+ });
+ let root_leaf = first_layout_leaf(&params.root);
+ let first_cwd = self.layout_root_cwd(ws_idx, replace_target, root_leaf);
+ let (rows, cols) = self.state.estimate_pane_size();
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
+ let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let command = match layout_command(root_leaf) {
+ Ok(command) => command,
+ Err(message) => return encode_error(id, "invalid_layout", message),
+ };
+
+ let created = {
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ if let Some(argv) = command.as_deref() {
+ ws.create_tab_argv_command(
+ rows,
+ cols,
+ first_cwd,
+ argv,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ )
+ } else {
+ ws.create_tab(
+ rows,
+ cols,
+ first_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
+ )
+ }
+ };
+
+ let (new_tab_idx, terminal, runtime) = match created {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "layout_apply_failed", err.to_string()),
+ };
+ let new_root_pane = self.state.workspaces[ws_idx].tabs[new_tab_idx].root_pane;
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
+ self.state.remove_alias_shadowed_by_new_pane(new_root_pane);
+ self.state.terminals.insert(terminal.id.clone(), terminal);
+ if let Some(label) = replacement_label {
+ self.state.workspaces[ws_idx].tabs[new_tab_idx].set_custom_name(label);
+ }
+ self.apply_layout_pane_label(ws_idx, new_root_pane, root_leaf);
+
+ if let Err(message) = self.apply_layout_node_to_pane(ws_idx, new_root_pane, &params.root) {
+ self.rollback_layout_tab(ws_idx, new_root_pane);
+ return encode_error(id, "layout_apply_failed", message);
+ }
+
+ if let Some((target_ws_idx, target_tab_idx)) = replace_target {
+ let closed_tab_id = self
+ .public_tab_id(target_ws_idx, target_tab_idx)
+ .unwrap_or_else(|| {
+ format!(
+ "{}:{}",
+ self.public_workspace_id(target_ws_idx),
+ target_tab_idx + 1
+ )
+ });
+ let terminal_ids = self
+ .state
+ .terminal_ids_for_tab(target_ws_idx, target_tab_idx);
+ let Some(ws) = self.state.workspaces.get_mut(target_ws_idx) else {
+ return encode_error(id, "tab_not_found", "tab not found");
+ };
+ if ws.close_tab(target_tab_idx) {
+ self.state.remove_unattached_terminal_ids(terminal_ids);
+ self.shutdown_detached_terminal_runtimes();
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabClosed,
+ data: EventData::TabClosed {
+ tab_id: closed_tab_id,
+ workspace_id: self.public_workspace_id(target_ws_idx),
+ },
+ });
+ }
+ }
+
+ let Some(new_tab_idx) = self.state.workspaces[ws_idx]
+ .tabs
+ .iter()
+ .position(|tab| tab.root_pane == new_root_pane)
+ else {
+ return encode_error(id, "layout_apply_failed", "new layout tab disappeared");
+ };
+
+ if params.focus || replace_was_active {
+ self.state.switch_workspace_tab(ws_idx, new_tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ if let Some(tab) = self.tab_info(ws_idx, new_tab_idx) {
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab },
+ });
+ }
+ for pane_id in self.state.workspaces[ws_idx].tabs[new_tab_idx]
+ .layout
+ .pane_ids()
+ {
+ if let Some(pane) = self.pane_info(ws_idx, pane_id) {
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneCreated,
+ data: EventData::PaneCreated { pane },
+ });
+ }
+ }
+
+ let Some(layout) = self.layout_description(ws_idx, new_tab_idx) else {
+ return encode_error(id, "layout_apply_failed", "new layout unavailable");
+ };
+ encode_success(id, ResponseResult::LayoutApply { layout })
+ }
+
+ fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> {
+ match (params.tab_id.as_deref(), params.pane_id.as_deref()) {
+ (Some(_), Some(_)) => None,
+ (Some(tab_id), None) => self.parse_tab_id(tab_id),
+ (None, Some(pane_id)) => {
+ let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?;
+ let tab_idx = self
+ .state
+ .workspaces
+ .get(ws_idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx))
+ }
+ (None, None) => {
+ let ws_idx = self.state.active?;
+ let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index();
+ Some((ws_idx, tab_idx))
+ }
+ }
+ }
+
+ fn layout_description(&self, ws_idx: usize, tab_idx: usize) -> Option<LayoutDescription> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ Some(LayoutDescription {
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ zoomed: tab.zoomed,
+ focused_pane_id: self.public_pane_id(ws_idx, tab.layout.focused())?,
+ root: self.layout_node_description(ws_idx, tab_idx, tab.layout.root())?,
+ })
+ }
+
+ fn layout_node_description(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ node: &Node,
+ ) -> Option<LayoutNode> {
+ match node {
+ Node::Pane(pane_id) => Some(LayoutNode::Pane {
+ pane: self.layout_pane_description(ws_idx, tab_idx, *pane_id)?,
+ }),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => Some(LayoutNode::Split {
+ direction: match direction {
+ Direction::Horizontal => SplitDirection::Right,
+ Direction::Vertical => SplitDirection::Down,
+ },
+ ratio: *ratio,
+ first: Box::new(self.layout_node_description(ws_idx, tab_idx, first)?),
+ second: Box::new(self.layout_node_description(ws_idx, tab_idx, second)?),
+ }),
+ }
+ }
+
+ fn layout_pane_description(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: PaneId,
+ ) -> Option<LayoutPane> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ let terminal_id = tab.terminal_id(pane_id)?;
+ let terminal = self.state.terminals.get(terminal_id);
+ Some(LayoutPane {
+ pane_id: Some(self.public_pane_id(ws_idx, pane_id)?),
+ label: terminal.and_then(|terminal| terminal.manual_label.clone()),
+ cwd: tab
+ .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
+ .map(|cwd| cwd.display().to_string()),
+ command: terminal.and_then(|terminal| terminal.launch_argv.clone()),
+ env: Default::default(),
+ })
+ }
+
+ fn layout_root_cwd(
+ &self,
+ ws_idx: usize,
+ replace_target: Option<(usize, usize)>,
+ pane: &LayoutPane,
+ ) -> PathBuf {
+ if let Some(cwd) = pane.cwd.as_ref() {
+ return PathBuf::from(cwd);
+ }
+ let follow_cwd = replace_target.and_then(|(_, tab_idx)| {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ tab.cwd_for_pane(
+ tab.layout.focused(),
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ });
+ self.resolve_new_terminal_cwd(follow_cwd.or_else(|| {
+ self.state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ .and_then(|runtime| runtime.cwd())
+ }))
+ }
+
+ fn apply_layout_node_to_pane(
+ &mut self,
+ ws_idx: usize,
+ pane_id: PaneId,
+ node: &LayoutNode,
+ ) -> Result<(), String> {
+ match node {
+ LayoutNode::Pane { pane } => {
+ self.apply_layout_pane_label(ws_idx, pane_id, pane);
+ Ok(())
+ }
+ LayoutNode::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let second_leaf = first_layout_leaf(second);
+ let new_pane = self.layout_split_pane(
+ ws_idx,
+ pane_id,
+ direction.clone(),
+ *ratio,
+ second_leaf,
+ )?;
+ self.apply_layout_node_to_pane(ws_idx, pane_id, first)?;
+ self.apply_layout_node_to_pane(ws_idx, new_pane, second)
+ }
+ }
+ }
+
+ fn layout_split_pane(
+ &mut self,
+ ws_idx: usize,
+ target_pane_id: PaneId,
+ direction: SplitDirection,
+ ratio: f32,
+ pane: &LayoutPane,
+ ) -> Result<PaneId, String> {
+ let (rows, cols) = self.state.estimate_pane_size();
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
+ let cwd = pane.cwd.as_ref().map(PathBuf::from).or_else(|| {
+ self.state.workspaces.get(ws_idx).and_then(|ws| {
+ let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?;
+ ws.tabs.get(tab_idx)?.cwd_for_pane(
+ target_pane_id,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ })
+ });
+ let extra_env = super::env::normalize_launch_env(pane.env.clone())
+ .map_err(|(_, message)| message.to_string())?;
+ let direction = match direction {
+ SplitDirection::Right => Direction::Horizontal,
+ SplitDirection::Down => Direction::Vertical,
+ };
+ let command = layout_command(pane)?;
+ let result = {
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return Err("workspace not found".into());
+ };
+ if let Some(argv) = command.as_deref() {
+ ws.split_pane_argv_command_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ argv,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ false,
+ )
+ } else {
+ ws.split_pane_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
+ false,
+ )
+ }
+ };
+ let (_, new_pane) = result
+ .ok_or_else(|| "pane not found".to_string())?
+ .map_err(|err| err.to_string())?;
+ let new_pane_id = new_pane.pane_id;
+ self.attach_new_layout_pane(new_pane);
+ self.apply_layout_pane_label(ws_idx, new_pane_id, pane);
+ Ok(new_pane_id)
+ }
+
+ fn attach_new_layout_pane(&mut self, new_pane: NewPane) {
+ self.terminal_runtimes
+ .insert(new_pane.terminal.id.clone(), new_pane.runtime);
+ self.state
+ .remove_alias_shadowed_by_new_pane(new_pane.pane_id);
+ self.state
+ .terminals
+ .insert(new_pane.terminal.id.clone(), new_pane.terminal);
+ }
+
+ fn apply_layout_pane_label(&mut self, ws_idx: usize, pane_id: PaneId, pane: &LayoutPane) {
+ let Some(label) = pane
+ .label
+ .as_ref()
+ .map(|label| label.trim())
+ .filter(|label| !label.is_empty())
+ else {
+ return;
+ };
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(pane_id))
+ .cloned()
+ else {
+ return;
+ };
+ if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
+ terminal.set_manual_label(label.to_string());
+ }
+ }
+
+ fn rollback_layout_tab(&mut self, ws_idx: usize, root_pane: PaneId) {
+ let Some(tab_idx) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.iter().position(|tab| tab.root_pane == root_pane))
+ else {
+ return;
+ };
+ let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
+ if self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .is_some_and(|ws| ws.close_tab(tab_idx))
+ {
+ self.state.remove_unattached_terminal_ids(terminal_ids);
+ self.shutdown_detached_terminal_runtimes();
+ }
+ }
+}
+
+fn first_layout_leaf(node: &LayoutNode) -> &LayoutPane {
+ match node {
+ LayoutNode::Pane { pane } => pane,
+ LayoutNode::Split { first, .. } => first_layout_leaf(first),
+ }
+}
+
+fn layout_command(pane: &LayoutPane) -> Result<Option<Vec<String>>, String> {
+ match pane.command.as_ref() {
+ Some(command) if command.is_empty() => Err("pane command must not be empty".into()),
+ Some(command) => Ok(Some(command.clone())),
+ None => Ok(None),
+ }
+}
+
+fn validate_layout_tree(root: &LayoutNode) -> Result<(), String> {
+ let mut stats = LayoutTreeStats {
+ panes: 0,
+ max_depth: 0,
+ };
+ validate_layout_node(root, 1, &mut stats)?;
+ if stats.panes > MAX_LAYOUT_PANES {
+ return Err(format!(
+ "layout has {} panes; maximum is {}",
+ stats.panes, MAX_LAYOUT_PANES
+ ));
+ }
+ if stats.max_depth > MAX_LAYOUT_DEPTH {
+ return Err(format!(
+ "layout depth is {}; maximum is {}",
+ stats.max_depth, MAX_LAYOUT_DEPTH
+ ));
+ }
+ Ok(())
+}
+
+struct LayoutTreeStats {
+ panes: usize,
+ max_depth: usize,
+}
+
+fn validate_layout_node(
+ node: &LayoutNode,
+ depth: usize,
+ stats: &mut LayoutTreeStats,
+) -> Result<(), String> {
+ stats.max_depth = stats.max_depth.max(depth);
+ if depth > MAX_LAYOUT_DEPTH {
+ return Err(format!(
+ "layout depth is {}; maximum is {}",
+ depth, MAX_LAYOUT_DEPTH
+ ));
+ }
+ match node {
+ LayoutNode::Pane { pane } => {
+ stats.panes += 1;
+ if stats.panes > MAX_LAYOUT_PANES {
+ return Err(format!("layout has more than {} panes", MAX_LAYOUT_PANES));
+ }
+ layout_command(pane)?;
+ super::env::normalize_launch_env(pane.env.clone())
+ .map_err(|(_, message)| message.to_string())?;
+ Ok(())
+ }
+ LayoutNode::Split {
+ first,
+ second,
+ ratio,
+ ..
+ } => {
+ if !ratio.is_finite() {
+ return Err("split ratio must be finite".into());
+ }
+ validate_layout_node(first, depth + 1, stats)?;
+ validate_layout_node(second, depth + 1, stats)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ api::schema::{ErrorResponse, ResponseResult, SuccessResponse},
+ config::Config,
+ workspace::Workspace,
+ };
+
+ fn app_with_workspace() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("layout")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.ensure_test_terminals();
+ app
+ }
+
+ #[test]
+ fn layout_export_returns_portable_tree() {
+ let mut app = app_with_workspace();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(Direction::Horizontal);
+ app.state.ensure_test_terminals();
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ app.state.workspaces[0].tabs[0]
+ .layout
+ .set_ratio_at(&[], 0.65);
+ let right_terminal_id = app.state.workspaces[0].tabs[0]
+ .terminal_id(right)
+ .cloned()
+ .unwrap();
+ app.state
+ .terminals
+ .get_mut(&right_terminal_id)
+ .unwrap()
+ .set_manual_label("tests".into());
+
+ let response = app.handle_layout_export(
+ "req".into(),
+ LayoutExportParams {
+ tab_id: None,
+ pane_id: None,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::LayoutExport { layout } = success.result else {
+ panic!("expected layout export response");
+ };
+ assert_eq!(layout.workspace_id, app.public_workspace_id(0));
+ assert_eq!(layout.focused_pane_id, app.public_pane_id(0, root).unwrap());
+ let LayoutNode::Split {
+ direction,
+ ratio,
+ second,
+ ..
+ } = layout.root
+ else {
+ panic!("expected split layout root");
+ };
+ assert_eq!(direction, SplitDirection::Right);
+ assert!((ratio - 0.65).abs() < f32::EPSILON);
+ let LayoutNode::Pane { pane } = *second else {
+ panic!("expected second pane");
+ };
+ assert_eq!(pane.label.as_deref(), Some("tests"));
+ assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap()));
+ }
+
+ #[tokio::test]
+ async fn layout_apply_replaces_tab_with_requested_tree() {
+ let mut app = app_with_workspace();
+ let original_tab_id = app.public_tab_id(0, 0).unwrap();
+
+ let response = app.handle_layout_apply(
+ "req".into(),
+ LayoutApplyParams {
+ workspace_id: None,
+ tab_id: Some(original_tab_id),
+ tab_label: Some("dev".into()),
+ focus: true,
+ root: LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.7,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("tests".into()),
+ command: Some(vec!["sh".into(), "-c".into(), "true".into()]),
+ env: std::collections::HashMap::from([(
+ "HERDR_ROLE".into(),
+ "tests".into(),
+ )]),
+ ..Default::default()
+ },
+ }),
+ },
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::LayoutApply { layout } = success.result else {
+ panic!("expected layout apply response");
+ };
+ assert_eq!(app.state.workspaces[0].tabs.len(), 1);
+ assert_eq!(app.state.workspaces[0].tabs[0].display_name(), "dev");
+ let LayoutNode::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } = layout.root
+ else {
+ panic!("expected split layout root");
+ };
+ assert_eq!(direction, SplitDirection::Right);
+ assert!((ratio - 0.7).abs() < f32::EPSILON);
+ let LayoutNode::Pane { pane: first_pane } = *first else {
+ panic!("expected first pane");
+ };
+ let LayoutNode::Pane { pane: second_pane } = *second else {
+ panic!("expected second pane");
+ };
+ assert_eq!(first_pane.label.as_deref(), Some("editor"));
+ assert_eq!(second_pane.label.as_deref(), Some("tests"));
+ assert_eq!(
+ second_pane.command,
+ Some(vec!["sh".into(), "-c".into(), "true".into()])
+ );
+ }
+
+ #[tokio::test]
+ async fn layout_apply_rejects_invalid_deep_leaf_without_creating_tab() {
+ let mut app = app_with_workspace();
+ let original_tab_count = app.state.workspaces[0].tabs.len();
+
+ let response = app.handle_layout_apply(
+ "req".into(),
+ LayoutApplyParams {
+ workspace_id: Some(app.public_workspace_id(0)),
+ tab_id: None,
+ tab_label: Some("bad".into()),
+ focus: false,
+ root: LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.5,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ command: Some(Vec::new()),
+ ..Default::default()
+ },
+ }),
+ },
+ },
+ );
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_layout");
+ assert_eq!(app.state.workspaces[0].tabs.len(), original_tab_count);
+ }
+
+ #[test]
+ fn layout_validation_rejects_too_many_panes() {
+ let mut root = LayoutNode::Pane {
+ pane: LayoutPane::default(),
+ };
+ for _ in 0..MAX_LAYOUT_PANES {
+ root = LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.5,
+ first: Box::new(root),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane::default(),
+ }),
+ };
+ }
+
+ let err = validate_layout_tree(&root).unwrap_err();
+ assert!(err.contains("maximum"));
+ }
+}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 7ef4ed3c..2d994dca 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1,16 +1,17 @@
use bytes::Bytes;
use crate::api::schema::{
- EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection,
- PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason,
- PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect,
- PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, PaneMoveParams,
- PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
- PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
- PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
- PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams,
- PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
+ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams,
+ PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams,
+ PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams,
+ PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination,
+ PaneMoveParams, PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult,
+ PaneProcessInfo, PaneProcessInfoParams, PaneProcessInfoProcess, PaneReadParams, PaneReadResult,
+ PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult,
+ PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams,
+ PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason,
+ PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
};
use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason};
use crate::app::{App, Mode};
@@ -22,6 +23,10 @@ use super::super::api_helpers::{
};
use super::responses::{encode_error, encode_success};
+const METADATA_SOURCE_MAX_CHARS: usize = 80;
+const METADATA_TTL_MIN_MS: u64 = 1;
+const METADATA_TTL_MAX_MS: u64 = 86_400_000;
+
impl App {
pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
@@ -40,6 +45,10 @@ impl App {
let Some((ws_idx, target_pane_id)) = target else {
return encode_error(id, "pane_not_found", "pane not found");
};
+ let extra_env = match super::env::normalize_launch_env(params.env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
let (rows, cols) = self.state.estimate_pane_size();
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| {
@@ -75,6 +84,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
None => ws.split_pane(
@@ -86,6 +96,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
};
@@ -124,6 +135,21 @@ impl App {
}
}
+ pub(super) fn handle_pane_current(&mut self, id: String, params: PaneCurrentParams) -> String {
+ let target = match params.caller_pane_id.as_deref() {
+ Some(caller_pane_id) => self.parse_pane_id(caller_pane_id),
+ None => self.resolve_optional_pane(None),
+ };
+ let Some((ws_idx, pane_id)) = target else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+
+ encode_success(id, ResponseResult::PaneCurrent { pane })
+ }
+
pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
return pane_not_found(id, &target.pane_id);
@@ -152,6 +178,54 @@ impl App {
encode_success(id, ResponseResult::PaneLayout { layout })
}
+ pub(super) fn handle_pane_process_info(
+ &mut self,
+ id: String,
+ params: PaneProcessInfoParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let shell_pid = runtime.child_pid();
+ let foreground_job = shell_pid.and_then(crate::detect::foreground_job);
+ let foreground_process_group_id = foreground_job.as_ref().map(|job| job.process_group_id);
+ let foreground_processes = foreground_job
+ .map(|job| {
+ job.processes
+ .into_iter()
+ .map(|process| PaneProcessInfoProcess {
+ pid: process.pid,
+ name: process.name,
+ argv0: process.argv0,
+ argv: process.argv,
+ cmdline: process.cmdline,
+ cwd: crate::platform::process_cwd(process.pid)
+ .map(|cwd| cwd.display().to_string()),
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ encode_success(
+ id,
+ ResponseResult::PaneProcessInfo {
+ process_info: PaneProcessInfo {
+ pane_id: public_pane_id,
+ shell_pid,
+ foreground_process_group_id,
+ tty: None,
+ foreground_processes,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_neighbor(
&mut self,
id: String,
@@ -910,6 +984,7 @@ impl App {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: closed_workspace_id.clone(),
+ workspace: None,
},
});
}
@@ -1184,29 +1259,26 @@ impl App {
},
None => None,
};
- let Some(source) = normalize_optional_text(Some(params.source)) else {
- return encode_error(id, "invalid_metadata_request", "missing metadata source");
+ let source = match normalize_metadata_source(params.source) {
+ Ok(source) => source,
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
};
let raw_title_set = params.title.is_some();
let raw_display_agent_set = params.display_agent.is_some();
let raw_custom_status_set = params.custom_status.is_some();
let raw_state_labels_set = !params.state_labels.is_empty();
- let ttl = params.ttl_ms.map(std::time::Duration::from_millis);
+ let ttl = match normalize_metadata_ttl(params.ttl_ms) {
+ Ok(ttl) => ttl,
+ Err(message) => return encode_error(id, "invalid_metadata_ttl", message),
+ };
let title = normalize_presentation_text(params.title);
let display_agent = normalize_presentation_text(params.display_agent);
let custom_status = normalize_custom_status(params.custom_status);
let applies_to_source = match params.applies_to_source {
- Some(applies_to_source) => {
- let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source))
- else {
- return encode_error(
- id,
- "invalid_metadata_request",
- "missing metadata authority source",
- );
- };
- Some(applies_to_source)
- }
+ Some(applies_to_source) => match normalize_metadata_source(applies_to_source) {
+ Ok(applies_to_source) => Some(applies_to_source),
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
+ },
None => None,
};
let state_labels = match normalize_state_labels(params.state_labels) {
@@ -1353,29 +1425,39 @@ impl App {
}
pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String {
+ match self.close_pane(id.clone(), &target) {
+ Ok(()) => encode_success(id, ResponseResult::Ok {}),
+ Err(response) => response,
+ }
+ }
+
+ /// Close a pane; `Err` carries the encoded error response.
+ pub(super) fn close_pane(&mut self, id: String, target: &PaneTarget) -> Result<(), String> {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let workspace_id = self.public_workspace_id(ws_idx);
if self.state.close_pane_would_close_workspace(ws_idx, pane_id)
&& self.state.confirm_implicit_worktree_group_close(ws_idx)
{
- return encode_error(
+ return Err(encode_error(
id,
"confirmation_required",
"closing this pane would close a worktree group",
- );
+ ));
}
+ let workspace_snapshot = self.workspace_info(ws_idx);
let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id);
let should_close_workspace = {
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
ws.close_pane(pane_id)
};
+ self.state.plugin_panes.remove(&pane_id);
if should_close_workspace {
self.state.selected = ws_idx;
self.state.close_selected_workspace();
@@ -1389,7 +1471,10 @@ impl App {
});
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed { workspace_id },
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace_snapshot),
+ },
});
} else {
self.state.remove_unattached_terminal_ids(terminal_id);
@@ -1404,7 +1489,7 @@ impl App {
});
}
- encode_success(id, ResponseResult::Ok {})
+ Ok(())
}
pub(super) fn handle_pane_send_keys(
@@ -1432,9 +1517,39 @@ impl App {
}
}
-fn normalize_optional_text(value: Option<String>) -> Option<String> {
- let value = value?.trim().to_string();
- (!value.is_empty()).then_some(value)
+fn normalize_metadata_source(value: String) -> Result<String, &'static str> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err("metadata source must not be empty");
+ }
+ if value.chars().count() > METADATA_SOURCE_MAX_CHARS {
+ return Err("metadata source must be 80 characters or fewer");
+ }
+ if !value.chars().all(metadata_source_char_is_valid) {
+ return Err(
+ "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen",
+ );
+ }
+ Ok(value.to_string())
+}
+
+fn metadata_source_char_is_valid(ch: char) -> bool {
+ ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')
+}
+
+fn normalize_metadata_ttl(
+ ttl_ms: Option<u64>,
+) -> Result<Option<std::time::Duration>, &'static str> {
+ let Some(ttl_ms) = ttl_ms else {
+ return Ok(None);
+ };
+ if ttl_ms < METADATA_TTL_MIN_MS {
+ return Err("metadata ttl_ms must be at least 1");
+ }
+ if ttl_ms > METADATA_TTL_MAX_MS {
+ return Err("metadata ttl_ms must be 86400000 or less");
+ }
+ Ok(Some(std::time::Duration::from_millis(ttl_ms)))
}
fn normalize_presentation_text(value: Option<String>) -> Option<String> {
@@ -1659,11 +1774,51 @@ fn invalid_agent(id: String) -> String {
mod tests {
use super::*;
use crate::{
- api::schema::{SplitDirection, SuccessResponse},
+ api::schema::{ErrorResponse, SplitDirection, SuccessResponse},
config::Config,
workspace::Workspace,
};
+ fn app_with_test_workspace() -> (App, String) {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("metadata")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+ (app, public_pane_id)
+ }
+
+ fn metadata_params(pane_id: String) -> PaneReportMetadataParams {
+ PaneReportMetadataParams {
+ pane_id,
+ source: "user:metadata.test-1".into(),
+ agent: None,
+ applies_to_source: None,
+ title: None,
+ display_agent: None,
+ custom_status: Some("activity".into()),
+ state_labels: std::collections::HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ seq: None,
+ ttl_ms: None,
+ }
+ }
+
+ fn metadata_error_code(response: &str) -> String {
+ let response: ErrorResponse = serde_json::from_str(response).unwrap();
+ response.error.code
+ }
+
fn app_with_linked_worktree() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1674,6 +1829,7 @@ mod tests {
crate::api::EventHub::default(),
);
app.state.workspaces = vec![Workspace::test_new("issue")];
+ app.state.ensure_test_terminals();
app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
label: "herdr".into(),
@@ -1722,6 +1878,106 @@ mod tests {
}
#[test]
+ fn api_pane_current_prefers_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.ensure_test_terminals();
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some(right_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, right_public);
+ assert!(!pane.focused);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_ne!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_falls_back_to_focused_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ assert!(pane.focused);
+ }
+
+ #[test]
+ fn api_pane_current_dispatches_through_socket_request() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::PaneCurrent(
+ crate::api::schema::PaneCurrentParams::default(),
+ ),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_reports_invalid_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some("missing".into()),
+ },
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
+ fn api_pane_current_reports_no_active_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = None;
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() {
let mut app = app_with_linked_worktree();
let source = app.state.workspaces[0].tabs[0].root_pane;
@@ -2825,4 +3081,64 @@ mod tests {
assert_eq!(focus.focused_pane_id, Some(root_public));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
}
+
+ #[test]
+ fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.ttl_ms = Some(METADATA_TTL_MAX_MS);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_source_shape() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for source in ["", "user metadata", "user/metadata", "user:\u{7f}metadata"] {
+ let mut params = metadata_params(pane_id.clone());
+ params.source = source.into();
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_long_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.source = "a".repeat(METADATA_SOURCE_MAX_CHARS + 1);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_applies_to_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.applies_to_source = Some("herdr source".into());
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_ttl_outside_supported_range() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for ttl_ms in [0, METADATA_TTL_MAX_MS + 1] {
+ let mut params = metadata_params(pane_id.clone());
+ params.ttl_ms = Some(ttl_ms);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_ttl");
+ }
+ }
}
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
new file mode 100644
index 00000000..881020b8
--- /dev/null
+++ b/src/app/api/plugins/context.rs
@@ -0,0 +1,379 @@
+use crate::api::schema::{EventData, PluginInvocationContext};
+use crate::app::App;
+
+impl App {
+ pub(super) fn merge_plugin_context(
+ &self,
+ provided: Option<PluginInvocationContext>,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let mut context = self.current_plugin_context(correlation_id);
+ if let Some(provided) = provided {
+ context.workspace_id = provided.workspace_id.or(context.workspace_id);
+ context.workspace_label = provided.workspace_label.or(context.workspace_label);
+ context.workspace_cwd = provided.workspace_cwd.or(context.workspace_cwd);
+ context.worktree = provided.worktree.or(context.worktree);
+ context.tab_id = provided.tab_id.or(context.tab_id);
+ context.tab_label = provided.tab_label.or(context.tab_label);
+ context.focused_pane_id = provided.focused_pane_id.or(context.focused_pane_id);
+ context.focused_pane_cwd = provided.focused_pane_cwd.or(context.focused_pane_cwd);
+ context.focused_pane_agent = provided.focused_pane_agent.or(context.focused_pane_agent);
+ context.focused_pane_status =
+ provided.focused_pane_status.or(context.focused_pane_status);
+ context.selected_text = provided.selected_text.or(context.selected_text);
+ context.invocation_source = provided.invocation_source.or(context.invocation_source);
+ context.correlation_id = provided.correlation_id.or(context.correlation_id);
+ context.clicked_url = provided.clicked_url.or(context.clicked_url);
+ context.link_handler_id = provided.link_handler_id.or(context.link_handler_id);
+ }
+ context
+ }
+
+ pub(super) fn current_plugin_context(&self, correlation_id: &str) -> PluginInvocationContext {
+ let Some(ws_idx) = self.state.active else {
+ return empty_plugin_context(correlation_id);
+ };
+ self.plugin_context_for_workspace(ws_idx, correlation_id)
+ }
+
+ pub(super) fn plugin_context_for_event(
+ &self,
+ event: &crate::api::schema::EventEnvelope,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ match &event.data {
+ EventData::WorkspaceCreated { workspace }
+ | EventData::WorkspaceUpdated { workspace }
+ | EventData::WorktreeCreated { workspace, .. }
+ | EventData::WorktreeOpened { workspace, .. } => {
+ self.plugin_context_for_workspace_info(workspace, correlation_id)
+ }
+ EventData::WorkspaceClosed {
+ workspace_id,
+ workspace,
+ } => workspace
+ .as_ref()
+ .map(|workspace| self.plugin_context_for_workspace_info(workspace, correlation_id))
+ .unwrap_or_else(|| {
+ self.plugin_context_for_workspace_id(workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context
+ })
+ }),
+ EventData::WorkspaceRenamed { workspace_id, .. }
+ | EventData::WorkspaceFocused { workspace_id }
+ | EventData::WorktreeRemoved { workspace_id, .. } => self
+ .plugin_context_for_workspace_id(workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context
+ }),
+ EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id),
+ EventData::TabClosed {
+ tab_id,
+ workspace_id,
+ } => {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.tab_id = Some(tab_id.clone());
+ context
+ }
+ EventData::TabRenamed {
+ tab_id,
+ workspace_id,
+ ..
+ }
+ | EventData::TabFocused {
+ tab_id,
+ workspace_id,
+ } => self
+ .plugin_context_for_tab_id(tab_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.tab_id = Some(tab_id.clone());
+ context
+ }),
+ EventData::PaneCreated { pane } => {
+ self.plugin_context_for_pane_info(pane, correlation_id)
+ }
+ EventData::PaneMoved { pane, .. } => {
+ self.plugin_context_for_pane_info(pane.as_ref(), correlation_id)
+ }
+ EventData::PaneClosed {
+ pane_id,
+ workspace_id,
+ } => {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.focused_pane_id = Some(pane_id.clone());
+ context
+ }
+ EventData::PaneFocused {
+ pane_id,
+ workspace_id,
+ }
+ | EventData::PaneOutputChanged {
+ pane_id,
+ workspace_id,
+ ..
+ }
+ | EventData::PaneExited {
+ pane_id,
+ workspace_id,
+ }
+ | EventData::PaneAgentDetected {
+ pane_id,
+ workspace_id,
+ ..
+ }
+ | EventData::PaneAgentStatusChanged {
+ pane_id,
+ workspace_id,
+ ..
+ } => self
+ .plugin_context_for_public_pane_id(pane_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.focused_pane_id = Some(pane_id.clone());
+ context
+ }),
+ }
+ }
+
+ fn plugin_context_for_workspace_id(
+ &self,
+ workspace_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let ws_idx = self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .find_map(|(idx, _)| (self.public_workspace_id(idx) == workspace_id).then_some(idx))?;
+ Some(self.plugin_context_for_workspace(ws_idx, correlation_id))
+ }
+
+ fn plugin_context_for_workspace_info(
+ &self,
+ workspace: &crate::api::schema::WorkspaceInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace.workspace_id.clone());
+ context.workspace_label = Some(workspace.label.clone());
+ context.workspace_cwd = workspace
+ .worktree
+ .as_ref()
+ .map(|worktree| worktree.checkout_path.clone());
+ context.worktree = workspace.worktree.clone();
+ context.tab_id = Some(workspace.active_tab_id.clone());
+ context
+ })
+ }
+
+ fn plugin_context_for_tab_id(
+ &self,
+ tab_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let (ws_idx, tab_idx) = self.parse_tab_id(tab_id)?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let workspace = self.workspace_info(ws_idx);
+ let tab = ws.tabs.get(tab_idx)?;
+ let pane_id = tab.layout.focused();
+ let focused_pane = self.pane_info(ws_idx, pane_id);
+ Some(self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ self.public_tab_id(ws_idx, tab_idx),
+ Some(tab.display_name()),
+ focused_pane,
+ correlation_id,
+ ))
+ }
+
+ fn plugin_context_for_tab_info(
+ &self,
+ tab: &crate::api::schema::TabInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_tab_id(&tab.tab_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(&tab.workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(tab.workspace_id.clone());
+ context.tab_id = Some(tab.tab_id.clone());
+ context.tab_label = Some(tab.label.clone());
+ context
+ })
+ }
+
+ fn plugin_context_for_public_pane_id(
+ &self,
+ pane_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?;
+ Some(self.plugin_context_for_pane(ws_idx, pane_id, correlation_id))
+ }
+
+ fn plugin_context_for_pane_info(
+ &self,
+ pane: &crate::api::schema::PaneInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_public_pane_id(&pane.pane_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(&pane.workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(pane.workspace_id.clone());
+ context.tab_id = Some(pane.tab_id.clone());
+ context.focused_pane_id = Some(pane.pane_id.clone());
+ context.focused_pane_cwd = pane.cwd.clone();
+ context.focused_pane_agent = pane.agent.clone();
+ context.focused_pane_status = Some(pane.agent_status);
+ context
+ })
+ }
+
+ pub(super) fn plugin_context_for_workspace(
+ &self,
+ ws_idx: usize,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return empty_plugin_context(correlation_id);
+ };
+ let workspace = self.workspace_info(ws_idx);
+ let tab_idx = ws.active_tab_index();
+ let tab_id = self.public_tab_id(ws_idx, tab_idx);
+ let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name());
+ let focused_pane = ws
+ .focused_pane_id()
+ .and_then(|pane_id| self.pane_info(ws_idx, pane_id));
+ self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ tab_id,
+ tab_label,
+ focused_pane,
+ correlation_id,
+ )
+ }
+
+ pub(super) fn plugin_context_for_pane(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let ws = &self.state.workspaces[ws_idx];
+ let workspace = self.workspace_info(ws_idx);
+ let tab_idx = ws
+ .find_tab_index_for_pane(pane_id)
+ .unwrap_or_else(|| ws.active_tab_index());
+ let tab_id = self.public_tab_id(ws_idx, tab_idx);
+ let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name());
+ let focused_pane = self.pane_info(ws_idx, pane_id);
+ self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ tab_id,
+ tab_label,
+ focused_pane,
+ correlation_id,
+ )
+ }
+
+ fn plugin_context_from_parts(
+ &self,
+ ws_idx: usize,
+ workspace: crate::api::schema::WorkspaceInfo,
+ tab_id: Option<String>,
+ tab_label: Option<String>,
+ focused_pane: Option<crate::api::schema::PaneInfo>,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let workspace_cwd = focused_pane
+ .as_ref()
+ .and_then(|pane| pane.cwd.clone())
+ .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string()));
+ let selected_text = focused_pane
+ .as_ref()
+ .and_then(|pane| self.parse_pane_id(&pane.pane_id))
+ .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id));
+ PluginInvocationContext {
+ workspace_id: Some(workspace.workspace_id),
+ workspace_label: Some(workspace.label),
+ workspace_cwd,
+ worktree: workspace.worktree,
+ tab_id,
+ tab_label,
+ focused_pane_id: focused_pane.as_ref().map(|pane| pane.pane_id.clone()),
+ focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()),
+ focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()),
+ focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status),
+ selected_text,
+ invocation_source: Some("api".to_string()),
+ correlation_id: Some(correlation_id.to_string()),
+ clicked_url: None,
+ link_handler_id: None,
+ }
+ }
+
+ fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> {
+ let selection = self.state.selection.as_ref()?;
+ if selection.pane_id != pane_id || !selection.is_visible() {
+ return None;
+ }
+ let terminal_id = self
+ .state
+ .workspaces
+ .iter()
+ .find_map(|workspace| workspace.terminal_id(pane_id))?;
+ self.terminal_runtimes
+ .get(terminal_id)
+ .and_then(|runtime| runtime.extract_selection(selection))
+ .filter(|text| !text.is_empty())
+ }
+
+ fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf {
+ self.state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ })
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()))
+ }
+}
+
+fn empty_plugin_context(correlation_id: &str) -> PluginInvocationContext {
+ PluginInvocationContext {
+ workspace_id: None,
+ workspace_label: None,
+ workspace_cwd: None,
+ worktree: None,
+ tab_id: None,
+ tab_label: None,
+ focused_pane_id: None,
+ focused_pane_cwd: None,
+ focused_pane_agent: None,
+ focused_pane_status: None,
+ selected_text: None,
+ invocation_source: Some("api".to_string()),
+ correlation_id: Some(correlation_id.to_string()),
+ clicked_url: None,
+ link_handler_id: None,
+ }
+}
diff --git a/src/app/api/plugins/env.rs b/src/app/api/plugins/env.rs
new file mode 100644
index 00000000..13644a19
--- /dev/null
+++ b/src/app/api/plugins/env.rs
@@ -0,0 +1,19 @@
+use crate::api::schema::InstalledPluginInfo;
+
+pub(super) fn plugin_path_env(plugin: &InstalledPluginInfo) -> Vec<(String, String)> {
+ let component = crate::api::schema::plugin_managed_path_component(&plugin.plugin_id);
+ let config_dir = crate::config::config_dir().join("plugins").join(&component);
+ let state_dir = crate::config::state_dir().join("plugins").join(component);
+
+ vec![
+ ("HERDR_PLUGIN_ROOT".to_string(), plugin.plugin_root.clone()),
+ (
+ "HERDR_PLUGIN_CONFIG_DIR".to_string(),
+ config_dir.display().to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_STATE_DIR".to_string(),
+ state_dir.display().to_string(),
+ ),
+ ]
+}
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
new file mode 100644
index 00000000..92799aae
--- /dev/null
+++ b/src/app/api/plugins/manifest.rs
@@ -0,0 +1,539 @@
+use crate::api::schema::{
+ InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook,
+ PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform,
+ PluginSourceInfo, PluginSourceKind,
+};
+
+const PLUGIN_ID_MAX_CHARS: usize = 120;
+const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120;
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifest {
+ id: String,
+ name: String,
+ version: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ #[serde(default)]
+ build: Vec<RawPluginManifestBuild>,
+ #[serde(default)]
+ actions: Vec<RawPluginManifestAction>,
+ #[serde(default)]
+ events: Vec<RawPluginManifestEventHook>,
+ #[serde(default)]
+ panes: Vec<RawPluginManifestPane>,
+ #[serde(default)]
+ link_handlers: Vec<RawPluginManifestLinkHandler>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestBuild {
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestAction {
+ id: String,
+ title: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ contexts: Vec<crate::api::schema::PluginActionContext>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestEventHook {
+ on: String,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestPane {
+ id: String,
+ title: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ #[serde(default)]
+ placement: PluginPanePlacement,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestLinkHandler {
+ id: String,
+ title: String,
+ pattern: String,
+ action: String,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+}
+
+/// Raw string platform value from the manifest, validated before conversion.
+#[derive(serde::Deserialize)]
+#[serde(try_from = "String")]
+struct RawPlatform(PluginPlatform);
+
+impl TryFrom<String> for RawPlatform {
+ type Error = String;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ match value.as_str() {
+ "linux" => Ok(RawPlatform(PluginPlatform::Linux)),
+ "macos" => Ok(RawPlatform(PluginPlatform::Macos)),
+ "windows" => Ok(RawPlatform(PluginPlatform::Windows)),
+ other => Err(format!(
+ "invalid_plugin_platform: unknown platform '{other}'"
+ )),
+ }
+ }
+}
+
+pub(crate) fn load_plugin_manifest(
+ path: &str,
+ enabled: bool,
+) -> Result<InstalledPluginInfo, (&'static str, String)> {
+ let path = std::path::PathBuf::from(path);
+ let manifest_path = if path.is_dir() {
+ path.join("herdr-plugin.toml")
+ } else {
+ path
+ };
+ let manifest_path = manifest_path
+ .canonicalize()
+ .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?;
+ let plugin_root = manifest_path
+ .parent()
+ .ok_or_else(|| {
+ (
+ "invalid_plugin_manifest_path",
+ "manifest path has no parent directory".to_string(),
+ )
+ })?
+ .to_path_buf();
+ let content = std::fs::read_to_string(&manifest_path)
+ .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?;
+ let raw: RawPluginManifest = toml::from_str(&content)
+ .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?;
+ let plugin_id = normalize_plugin_id(&raw.id)
+ .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?;
+ let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?;
+ let version = non_empty_trimmed(
+ &raw.version,
+ "invalid_plugin_version",
+ "plugin version is required",
+ )?;
+ let description = raw
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(raw.platforms)?;
+ let build = raw
+ .build
+ .into_iter()
+ .map(normalize_manifest_build)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut actions = raw
+ .actions
+ .into_iter()
+ .map(normalize_manifest_action)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_action_ids(&actions)?;
+ actions.sort_by(|a, b| a.id.cmp(&b.id));
+ let mut events = raw
+ .events
+ .into_iter()
+ .map(normalize_manifest_event)
+ .collect::<Result<Vec<_>, _>>()?;
+ events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command)));
+ let mut panes = raw
+ .panes
+ .into_iter()
+ .map(normalize_manifest_pane)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_pane_ids(&panes)?;
+ panes.sort_by(|a, b| a.id.cmp(&b.id));
+ let link_handlers = raw
+ .link_handlers
+ .into_iter()
+ .map(normalize_manifest_link_handler)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_link_handler_ids(&link_handlers)?;
+ validate_link_handler_actions(&link_handlers, &actions)?;
+
+ let mut warnings = validate_event_names(&events);
+ if platforms.is_none() {
+ warnings.push("manifest does not declare platforms; platform support unknown".to_string());
+ }
+
+ Ok(InstalledPluginInfo {
+ plugin_id,
+ name,
+ version,
+ description,
+ manifest_path: manifest_path.display().to_string(),
+ plugin_root: plugin_root.display().to_string(),
+ enabled,
+ platforms,
+ build,
+ actions,
+ events,
+ panes,
+ link_handlers,
+ source: Default::default(),
+ warnings,
+ })
+}
+
+fn normalize_manifest_build(
+ build: RawPluginManifestBuild,
+) -> Result<PluginManifestBuild, (&'static str, String)> {
+ let platforms = normalize_platforms(build.platforms)?;
+ let command = normalize_command(build.command)?;
+ Ok(PluginManifestBuild { platforms, command })
+}
+
+pub(super) fn normalize_plugin_source(
+ plugin: &InstalledPluginInfo,
+ source: PluginSourceInfo,
+) -> Result<PluginSourceInfo, (&'static str, String)> {
+ if source.kind == PluginSourceKind::Local {
+ return Ok(source);
+ }
+ let Some(managed_path) = source.managed_path.as_deref() else {
+ return Err((
+ "invalid_plugin_source",
+ "GitHub plugin source requires managed_path".to_string(),
+ ));
+ };
+ let managed_path = std::path::PathBuf::from(managed_path)
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ let plugin_root = std::path::PathBuf::from(&plugin.plugin_root)
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ let expected = crate::session::data_dir()
+ .join("plugins")
+ .join("github")
+ .join(crate::api::schema::plugin_managed_path_component(
+ &plugin.plugin_id,
+ ))
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ if managed_path != expected {
+ return Err((
+ "invalid_plugin_source",
+ "GitHub plugin managed_path does not match the plugin id".to_string(),
+ ));
+ }
+ if !plugin_root.starts_with(&managed_path) {
+ return Err((
+ "invalid_plugin_source",
+ "plugin manifest is not inside the managed checkout".to_string(),
+ ));
+ }
+ Ok(source)
+}
+
+fn reject_duplicate_action_ids(
+ actions: &[PluginManifestAction],
+) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for action in actions {
+ if !seen.insert(action.id.as_str()) {
+ return Err((
+ "duplicate_plugin_action_id",
+ format!("duplicate action id '{}'", action.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec<String> {
+ let known = crate::api::schema::plugin_hook_event_names();
+ events
+ .iter()
+ .filter(|hook| !known.contains(&hook.on.as_str()))
+ .map(|hook| format!("unknown event '{}'", hook.on))
+ .collect()
+}
+
+fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for pane in panes {
+ if !seen.insert(pane.id.as_str()) {
+ return Err((
+ "duplicate_plugin_pane_id",
+ format!("duplicate pane id '{}'", pane.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn reject_duplicate_link_handler_ids(
+ handlers: &[PluginManifestLinkHandler],
+) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for handler in handlers {
+ if !seen.insert(handler.id.as_str()) {
+ return Err((
+ "duplicate_plugin_link_handler_id",
+ format!("duplicate link handler id '{}'", handler.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_link_handler_actions(
+ handlers: &[PluginManifestLinkHandler],
+ actions: &[PluginManifestAction],
+) -> Result<(), (&'static str, String)> {
+ for handler in handlers {
+ if !actions.iter().any(|action| action.id == handler.action) {
+ return Err((
+ "invalid_plugin_link_handler_action",
+ format!(
+ "link handler '{}' references unknown action '{}'",
+ handler.id, handler.action
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn normalize_manifest_action(
+ action: RawPluginManifestAction,
+) -> Result<PluginManifestAction, (&'static str, String)> {
+ let id = normalize_action_id(&action.id)
+ .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?;
+ let title = non_empty_trimmed(
+ &action.title,
+ "invalid_plugin_action_title",
+ "action title is required",
+ )?;
+ let description = action
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(action.platforms)?;
+ let command = normalize_command(action.command)?;
+ Ok(PluginManifestAction {
+ id,
+ title,
+ description,
+ contexts: action.contexts,
+ platforms,
+ command,
+ })
+}
+
+fn normalize_manifest_pane(
+ pane: RawPluginManifestPane,
+) -> Result<PluginManifestPane, (&'static str, String)> {
+ let id = normalize_action_id(&pane.id)
+ .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?;
+ let title = non_empty_trimmed(
+ &pane.title,
+ "invalid_plugin_pane_title",
+ "pane title is required",
+ )?;
+ let description = pane
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(pane.platforms)?;
+ let command = normalize_command(pane.command)?;
+ Ok(PluginManifestPane {
+ id,
+ title,
+ description,
+ platforms,
+ placement: pane.placement,
+ command,
+ })
+}
+
+fn normalize_manifest_event(
+ event: RawPluginManifestEventHook,
+) -> Result<PluginManifestEventHook, (&'static str, String)> {
+ let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?;
+ let platforms = normalize_platforms(event.platforms)?;
+ let command = normalize_command(event.command)?;
+ Ok(PluginManifestEventHook {
+ on,
+ platforms,
+ command,
+ })
+}
+
+fn normalize_manifest_link_handler(
+ handler: RawPluginManifestLinkHandler,
+) -> Result<PluginManifestLinkHandler, (&'static str, String)> {
+ let id = normalize_action_id(&handler.id).ok_or_else(|| {
+ (
+ "invalid_plugin_link_handler_id",
+ "invalid link handler id".to_string(),
+ )
+ })?;
+ let title = non_empty_trimmed(
+ &handler.title,
+ "invalid_plugin_link_handler_title",
+ "link handler title is required",
+ )?;
+ let pattern = non_empty_trimmed(
+ &handler.pattern,
+ "invalid_plugin_link_handler_pattern",
+ "link handler pattern is required",
+ )?;
+ regex::Regex::new(&pattern)
+ .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?;
+ let action = normalize_action_id(&handler.action).ok_or_else(|| {
+ (
+ "invalid_plugin_link_handler_action",
+ "invalid link handler action".to_string(),
+ )
+ })?;
+ let platforms = normalize_platforms(handler.platforms)?;
+ Ok(PluginManifestLinkHandler {
+ id,
+ title,
+ pattern,
+ action,
+ platforms,
+ })
+}
+
+fn normalize_platforms(
+ raw: Option<Vec<RawPlatform>>,
+) -> Result<Option<Vec<PluginPlatform>>, (&'static str, String)> {
+ match raw {
+ None => Ok(None),
+ Some(list) if list.is_empty() => Err((
+ "invalid_plugin_platform",
+ "platforms must not be an empty array; omit the field to leave platforms undeclared"
+ .to_string(),
+ )),
+ Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())),
+ }
+}
+
+/// Returns the platform the current binary was compiled for.
+fn current_platform() -> PluginPlatform {
+ if cfg!(target_os = "linux") {
+ PluginPlatform::Linux
+ } else if cfg!(target_os = "macos") {
+ PluginPlatform::Macos
+ } else {
+ PluginPlatform::Windows
+ }
+}
+
+/// Resolve the effective platforms for an action or event: use the item's own
+/// platforms if declared, otherwise inherit from the plugin-level platforms.
+/// Returns a reference to whichever `Option<Vec<PluginPlatform>>` applies.
+pub(super) fn effective_platforms<'a>(
+ item_platforms: &'a Option<Vec<PluginPlatform>>,
+ plugin_platforms: &'a Option<Vec<PluginPlatform>>,
+) -> &'a Option<Vec<PluginPlatform>> {
+ if item_platforms.is_some() {
+ item_platforms
+ } else {
+ plugin_platforms
+ }
+}
+
+pub(super) fn ensure_platform_supported(
+ platforms: &Option<Vec<PluginPlatform>>,
+ subject: &str,
+) -> Result<(), (&'static str, String)> {
+ if let Some(platforms) = platforms {
+ let host = current_platform();
+ if !platforms.contains(&host) {
+ return Err((
+ "platform_unsupported",
+ format!(
+ "{subject} does not support the current platform ({})",
+ platform_name(host)
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn platform_name(p: PluginPlatform) -> &'static str {
+ match p {
+ PluginPlatform::Linux => "linux",
+ PluginPlatform::Macos => "macos",
+ PluginPlatform::Windows => "windows",
+ }
+}
+
+fn normalize_command(command: Vec<String>) -> Result<Vec<String>, (&'static str, String)> {
+ let command = command
+ .into_iter()
+ .map(|arg| arg.trim().to_string())
+ .collect::<Vec<_>>();
+ if command.is_empty() || command.iter().any(|arg| arg.is_empty()) {
+ return Err((
+ "invalid_plugin_command",
+ "command must contain non-empty argv strings".to_string(),
+ ));
+ }
+ Ok(command)
+}
+
+fn non_empty_trimmed(
+ value: &str,
+ code: &'static str,
+ message: &'static str,
+) -> Result<String, (&'static str, String)> {
+ let value = value.trim().to_string();
+ if value.is_empty() {
+ Err((code, message.to_string()))
+ } else {
+ Ok(value)
+ }
+}
+
+pub(super) fn normalize_plugin_id(value: &str) -> Option<String> {
+ normalize_identifier(value, PLUGIN_ID_MAX_CHARS)
+}
+
+pub(super) fn normalize_action_id(value: &str) -> Option<String> {
+ normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS)
+}
+
+fn normalize_identifier(value: &str, max_chars: usize) -> Option<String> {
+ let value = value.trim();
+ (!value.is_empty()
+ && value.chars().count() <= max_chars
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-')))
+ .then(|| value.to_string())
+}
+
+fn normalize_local_identifier(value: &str, max_chars: usize) -> Option<String> {
+ let value = value.trim();
+ (!value.is_empty()
+ && value.chars().count() <= max_chars
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-')))
+ .then(|| value.to_string())
+}
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
new file mode 100644
index 00000000..3304a256
--- /dev/null
+++ b/src/app/api/plugins/mod.rs
@@ -0,0 +1,2704 @@
+mod context;
+mod env;
+mod manifest;
+mod panes;
+mod runtime;
+
+use super::responses::{encode_error, encode_success};
+use crate::api::schema::{
+ InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams,
+ PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction,
+ PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo,
+ PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams,
+ ResponseResult,
+};
+use crate::app::App;
+use manifest::{
+ effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id,
+ normalize_plugin_source,
+};
+
+#[cfg(test)]
+use crate::api::schema::{PluginCommandStatus, PluginInvocationContext};
+pub(crate) use manifest::load_plugin_manifest;
+#[cfg(test)]
+use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT};
+
+impl App {
+ pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String {
+ let mut plugin = match load_plugin_manifest(&params.path, params.enabled) {
+ Ok(plugin) => plugin,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ if let Some(source) = params.source {
+ match normalize_plugin_source(&plugin, source) {
+ Ok(source) => plugin.source = source,
+ Err((code, message)) => return encode_error(id, code, message),
+ }
+ }
+ let previous = self.state.installed_plugins.get(&plugin.plugin_id).cloned();
+ self.state
+ .installed_plugins
+ .insert(plugin.plugin_id.clone(), plugin.clone());
+ if let Err(err) = self.save_plugin_registry() {
+ match previous {
+ Some(previous) => {
+ self.state
+ .installed_plugins
+ .insert(previous.plugin_id.clone(), previous);
+ }
+ None => {
+ self.state.installed_plugins.remove(&plugin.plugin_id);
+ }
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ encode_success(id, ResponseResult::PluginLinked { plugin })
+ }
+
+ pub(super) fn handle_plugin_list(&mut self, id: String, params: PluginListParams) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| plugin.plugin_id == plugin_id)
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ encode_success(id, ResponseResult::PluginList { plugins })
+ }
+
+ pub(super) fn handle_plugin_unlink(
+ &mut self,
+ id: String,
+ params: PluginUnlinkParams,
+ ) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&params.plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let previous = self.state.installed_plugins.remove(&plugin_id);
+ let removed = previous.is_some();
+ let previous_panes = if removed {
+ Some(self.state.plugin_panes.clone())
+ } else {
+ None
+ };
+ if removed {
+ // Drop plugin_panes records for this plugin (panes keep running).
+ self.state
+ .plugin_panes
+ .retain(|_, record| record.plugin_id != plugin_id);
+ if let Err(err) = self.save_plugin_registry() {
+ if let Some(previous) = previous {
+ self.state
+ .installed_plugins
+ .insert(plugin_id.clone(), previous);
+ }
+ if let Some(previous_panes) = previous_panes {
+ self.state.plugin_panes = previous_panes;
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ }
+ encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed })
+ }
+
+ pub(super) fn handle_plugin_enable(
+ &mut self,
+ id: String,
+ params: PluginSetEnabledParams,
+ ) -> String {
+ self.set_plugin_enabled(id, params.plugin_id, true)
+ }
+
+ pub(super) fn handle_plugin_disable(
+ &mut self,
+ id: String,
+ params: PluginSetEnabledParams,
+ ) -> String {
+ self.set_plugin_enabled(id, params.plugin_id, false)
+ }
+
+ pub(super) fn handle_plugin_action_list(
+ &mut self,
+ id: String,
+ params: PluginActionListParams,
+ ) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let mut actions = manifest_actions(&self.state.installed_plugins)
+ .filter(|action| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| action.plugin_id == plugin_id)
+ })
+ .collect::<Vec<_>>();
+ actions.sort_by_key(|action| action.qualified_id());
+ encode_success(id, ResponseResult::PluginActionList { actions })
+ }
+
+ pub(super) fn handle_plugin_action_invoke(
+ &mut self,
+ id: String,
+ params: PluginActionInvokeParams,
+ ) -> String {
+ let (plugin, action) =
+ match self.find_plugin_action(params.plugin_id.as_deref(), &params.action_id) {
+ Ok(pair) => pair,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ if !plugin.enabled {
+ return encode_error(
+ id,
+ "plugin_disabled",
+ format!("plugin {} is disabled", plugin.plugin_id),
+ );
+ }
+ if let Err((code, message)) = ensure_platform_supported(
+ effective_platforms(&action.platforms, &plugin.platforms),
+ &format!("action '{}'", action.qualified_id()),
+ ) {
+ return encode_error(id, code, message);
+ }
+ let context = self.merge_plugin_context(params.context, &id);
+ let log = match self.start_plugin_command(
+ &plugin,
+ Some(action.action_id.clone()),
+ None,
+ action.command.clone(),
+ &context,
+ None,
+ ) {
+ Ok(log) => log,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ encode_success(
+ id,
+ ResponseResult::PluginActionInvoked {
+ action,
+ context,
+ log,
+ },
+ )
+ }
+
+ pub(crate) fn invoke_plugin_action_from_keybind(
+ &mut self,
+ action_id: String,
+ ) -> Result<(), String> {
+ let (plugin, action) = self
+ .find_plugin_action(None, &action_id)
+ .map_err(|(_, message)| message)?;
+ if !plugin.enabled {
+ return Err(format!("plugin {} is disabled", plugin.plugin_id));
+ }
+ ensure_platform_supported(
+ effective_platforms(&action.platforms, &plugin.platforms),
+ &action.qualified_id(),
+ )
+ .map_err(|(_, message)| message)?;
+ let mut context = self.current_plugin_context("keybinding");
+ context.invocation_source = Some("keybinding".to_string());
+ self.start_plugin_command(
+ &plugin,
+ Some(action.action_id),
+ None,
+ action.command,
+ &context,
+ None,
+ )
+ .map(|_| ())
+ .map_err(|(_, message)| message)
+ }
+
+ pub(crate) fn invoke_plugin_link_handler_for_url(
+ &mut self,
+ url: &str,
+ pane_id: crate::layout::PaneId,
+ ) -> Result<bool, String> {
+ let Some((plugin, handler)) = self.find_plugin_link_handler(url) else {
+ return Ok(false);
+ };
+ if ensure_platform_supported(
+ &effective_platforms(&handler.platforms, &plugin.platforms).clone(),
+ &handler.id,
+ )
+ .is_err()
+ {
+ return Ok(false);
+ }
+ let action = plugin
+ .actions
+ .iter()
+ .find(|action| action.id == handler.action)
+ .cloned()
+ .ok_or_else(|| {
+ format!(
+ "plugin {} link handler {} references missing action {}",
+ plugin.plugin_id, handler.id, handler.action
+ )
+ })?;
+ ensure_platform_supported(
+ &effective_platforms(&action.platforms, &plugin.platforms).clone(),
+ &action.id,
+ )
+ .map_err(|(_, message)| message)?;
+ let Some(ws_idx) = self.state.active else {
+ return Ok(false);
+ };
+ let mut context = self.plugin_context_for_pane(ws_idx, pane_id, "link_click");
+ context.invocation_source = Some("link_click".to_string());
+ context.clicked_url = Some(url.to_string());
+ context.link_handler_id = Some(handler.id);
+ self.start_plugin_command(
+ &plugin,
+ Some(action.id),
+ None,
+ action.command,
+ &context,
+ None,
+ )
+ .map(|_| true)
+ .map_err(|(_, message)| message)
+ }
+
+ pub(super) fn handle_plugin_log_list(
+ &mut self,
+ id: String,
+ params: PluginLogListParams,
+ ) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let limit = params.limit.unwrap_or(50).clamp(1, 200);
+ let mut logs = self
+ .state
+ .plugin_command_logs
+ .iter()
+ .filter(|log| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| log.plugin_id == plugin_id)
+ })
+ .rev()
+ .take(limit)
+ .cloned()
+ .collect::<Vec<_>>();
+ logs.reverse();
+ encode_success(id, ResponseResult::PluginLogList { logs })
+ }
+
+ pub(super) fn handle_plugin_pane_open(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ ) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&params.plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ if !plugin_manifest_available(&plugin) {
+ return encode_error(
+ id,
+ "plugin_manifest_unavailable",
+ format!("plugin {plugin_id} manifest is unavailable"),
+ );
+ }
+ if !plugin.enabled {
+ return encode_error(
+ id,
+ "plugin_disabled",
+ format!("plugin {plugin_id} is disabled"),
+ );
+ }
+ let Some(entrypoint) = normalize_action_id(&params.entrypoint) else {
+ return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id");
+ };
+ let Some(pane) = plugin
+ .panes
+ .iter()
+ .find(|pane| pane.id == entrypoint)
+ .cloned()
+ else {
+ return encode_error(
+ id,
+ "plugin_pane_not_found",
+ format!("plugin pane entrypoint '{entrypoint}' not found"),
+ );
+ };
+ if let Err((code, message)) = ensure_platform_supported(
+ effective_platforms(&pane.platforms, &plugin.platforms),
+ "plugin pane",
+ ) {
+ return encode_error(id, code, message);
+ }
+ let placement = params.placement.unwrap_or(pane.placement);
+ match placement {
+ PluginPanePlacement::Overlay => {
+ if params.workspace_id.is_some()
+ || params.target_pane_id.is_some()
+ || params.direction.is_some()
+ {
+ return encode_error(
+ id,
+ "invalid_params",
+ "overlay plugin panes target the active pane",
+ );
+ }
+ }
+ PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
+ if params.workspace_id.is_some() {
+ return encode_error(
+ id,
+ "invalid_params",
+ "split and zoomed plugin panes target an existing pane; use target_pane_id",
+ );
+ }
+ }
+ PluginPanePlacement::Tab => {
+ if params.target_pane_id.is_some() || params.direction.is_some() {
+ return encode_error(
+ id,
+ "invalid_params",
+ "tab plugin panes support workspace_id but not target_pane_id or direction",
+ );
+ }
+ }
+ }
+
+ match placement {
+ PluginPanePlacement::Overlay => {
+ self.open_plugin_overlay_pane(id, params, &plugin, pane)
+ }
+ PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
+ self.open_plugin_split_pane(id, params, &plugin, pane, placement)
+ }
+ PluginPanePlacement::Tab => self.open_plugin_tab(id, params, &plugin, pane),
+ }
+ }
+
+ pub(super) fn handle_plugin_pane_focus(
+ &mut self,
+ id: String,
+ params: PluginPaneFocusParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ if !self.state.plugin_panes.contains_key(&pane_id) {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ }
+ self.state.focus_pane_in_workspace(ws_idx, pane_id);
+ self.state.mode = crate::app::Mode::Terminal;
+ let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ encode_success(
+ id,
+ ResponseResult::PluginPaneFocused {
+ plugin_pane: PluginPaneInfo {
+ plugin_id: record.plugin_id,
+ entrypoint: record.entrypoint,
+ pane,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_plugin_pane_close(
+ &mut self,
+ id: String,
+ params: PluginPaneCloseParams,
+ ) -> String {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ if !self.state.plugin_panes.contains_key(&pane_id) {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ }
+ let pane_id = params.pane_id;
+ if let Err(response) = self.close_pane(
+ id.clone(),
+ &crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ },
+ ) {
+ return response;
+ }
+ encode_success(id, ResponseResult::PluginPaneClosed { pane_id })
+ }
+
+ fn find_plugin_action(
+ &self,
+ plugin_id: Option<&str>,
+ action_id: &str,
+ ) -> Result<(crate::api::schema::InstalledPluginInfo, PluginActionInfo), (&'static str, String)>
+ {
+ if let Some(plugin_id) = plugin_id {
+ let plugin_id = normalize_plugin_id(plugin_id)
+ .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?;
+ let action_id = normalize_action_id(action_id)
+ .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?;
+ let plugin = self
+ .state
+ .installed_plugins
+ .get(&plugin_id)
+ .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?
+ .clone();
+ if !plugin_manifest_available(&plugin) {
+ return Err((
+ "plugin_manifest_unavailable",
+ format!("plugin {plugin_id} manifest is unavailable"),
+ ));
+ }
+ let action_info = plugin
+ .actions
+ .iter()
+ .find(|a| a.id == action_id)
+ .map(|a| manifest_action_info(&plugin_id, a))
+ .ok_or_else(|| {
+ (
+ "plugin_action_not_found",
+ "plugin action not found".to_string(),
+ )
+ })?;
+ return Ok((plugin, action_info));
+ }
+
+ let action_id = action_id.trim();
+ let matches = manifest_actions(&self.state.installed_plugins)
+ .filter(|action| action.action_id == action_id || action.qualified_id() == action_id)
+ .collect::<Vec<_>>();
+ match matches.as_slice() {
+ [action] => {
+ let plugin = self
+ .state
+ .installed_plugins
+ .get(&action.plugin_id)
+ .cloned()
+ .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?;
+ Ok((plugin, action.clone()))
+ }
+ [] => Err((
+ "plugin_action_not_found",
+ "plugin action not found".to_string(),
+ )),
+ _ => Err((
+ "ambiguous_plugin_action",
+ "plugin action id matches more than one action; include plugin_id".to_string(),
+ )),
+ }
+ }
+
+ fn find_plugin_link_handler(
+ &self,
+ url: &str,
+ ) -> Option<(InstalledPluginInfo, PluginManifestLinkHandler)> {
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| plugin.enabled && plugin_manifest_available(plugin))
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ for plugin in plugins {
+ for handler in &plugin.link_handlers {
+ if ensure_platform_supported(
+ &effective_platforms(&handler.platforms, &plugin.platforms).clone(),
+ &handler.id,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let Some(action) = plugin
+ .actions
+ .iter()
+ .find(|action| action.id == handler.action)
+ else {
+ continue;
+ };
+ if ensure_platform_supported(
+ &effective_platforms(&action.platforms, &plugin.platforms).clone(),
+ &action.id,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let Ok(regex) = regex::Regex::new(&handler.pattern) else {
+ continue;
+ };
+ if regex.is_match(url) {
+ return Some((plugin.clone(), handler.clone()));
+ }
+ }
+ }
+ None
+ }
+
+ fn set_plugin_enabled(&mut self, id: String, plugin_id: String, enabled: bool) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ let previous_enabled = plugin.enabled;
+ plugin.enabled = enabled;
+ if let Err(err) = self.save_plugin_registry() {
+ if let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) {
+ plugin.enabled = previous_enabled;
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ if enabled {
+ encode_success(id, ResponseResult::PluginEnabled { plugin })
+ } else {
+ encode_success(id, ResponseResult::PluginDisabled { plugin })
+ }
+ }
+
+ pub(crate) fn save_plugin_registry(&self) -> std::io::Result<()> {
+ if self.no_session {
+ return Ok(());
+ }
+ let plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .cloned()
+ .collect::<Vec<_>>();
+ crate::persist::plugin_registry::save(&plugins)
+ }
+}
+
+fn invalid_plugin_id(id: String) -> String {
+ encode_error(
+ id,
+ "invalid_plugin_id",
+ "plugin id must be non-empty, <= 120 characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen",
+ )
+}
+
+/// Normalize an optional plugin id filter; `Err` carries the encoded
+/// `invalid_plugin_id` error response.
+fn normalize_optional_plugin_id(
+ id: &str,
+ plugin_id: Option<String>,
+) -> Result<Option<String>, String> {
+ match plugin_id {
+ Some(plugin_id) => match normalize_plugin_id(&plugin_id) {
+ Some(plugin_id) => Ok(Some(plugin_id)),
+ None => Err(invalid_plugin_id(id.to_string())),
+ },
+ None => Ok(None),
+ }
+}
+
+fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool {
+ !plugin.warnings.iter().any(|warning| {
+ warning.starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX)
+ })
+}
+
+fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> PluginActionInfo {
+ PluginActionInfo {
+ plugin_id: plugin_id.to_string(),
+ action_id: action.id.clone(),
+ title: action.title.clone(),
+ description: action.description.clone(),
+ contexts: action.contexts.clone(),
+ command: action.command.clone(),
+ platforms: action.platforms.clone(),
+ }
+}
+
+fn manifest_actions(
+ plugins: &crate::app::state::InstalledPluginRegistry,
+) -> impl Iterator<Item = PluginActionInfo> + '_ {
+ plugins
+ .values()
+ .filter(|plugin| plugin_manifest_available(plugin))
+ .flat_map(|plugin| {
+ plugin
+ .actions
+ .iter()
+ .map(|action| manifest_action_info(&plugin.plugin_id, action))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{
+ Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
+ };
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ fn test_app() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ fn response_result(response: &str) -> ResponseResult {
+ serde_json::from_str::<SuccessResponse>(response)
+ .expect("success response")
+ .result
+ }
+
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn write_manifest(root: &std::path::Path) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(
+ &manifest,
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+description = "Prepare new worktrees"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["bun", "install"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap worktree"
+contexts = ["workspace"]
+command = ["bun", "run", "bootstrap.ts"]
+
+[[events]]
+on = "worktree.created"
+command = ["bun", "run", "bootstrap.ts"]
+
+[[panes]]
+id = "board"
+title = "Worktree board"
+command = ["bun", "run", "board.ts"]
+
+[[link_handlers]]
+id = "github-pr"
+title = "Open GitHub PR"
+pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
+action = "bootstrap"
+"#,
+ )
+ .unwrap();
+ manifest
+ }
+
+ fn write_manifest_content(root: &std::path::Path, content: &str) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(&manifest, content).unwrap();
+ manifest
+ }
+
+ fn link_manifest(app: &mut App, root: &std::path::Path) {
+ let result = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(
+ result.contains("plugin_linked"),
+ "expected plugin_linked: {result}"
+ );
+ }
+
+ #[test]
+ fn plugin_link_lists_and_unlinks_manifest() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-link");
+ write_manifest(&root);
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin linked response: {link}");
+ };
+ assert_eq!(plugin.plugin_id, "example.worktree-bootstrap");
+ assert_eq!(plugin.name, "Worktree Bootstrap");
+ assert_eq!(plugin.version, "0.1.0");
+ assert_eq!(plugin.plugin_root, root.display().to_string());
+ assert!(plugin.enabled);
+ assert_eq!(plugin.build.len(), 1);
+ assert_eq!(plugin.build[0].command, ["bun", "install"]);
+ assert_eq!(plugin.actions.len(), 1);
+ assert_eq!(plugin.actions[0].id, "bootstrap");
+ assert_eq!(plugin.actions[0].command, ["bun", "run", "bootstrap.ts"]);
+ assert_eq!(plugin.events.len(), 1);
+ assert_eq!(plugin.events[0].on, "worktree.created");
+ assert_eq!(plugin.panes.len(), 1);
+ assert_eq!(plugin.panes[0].id, "board");
+ assert_eq!(plugin.panes[0].placement, PluginPanePlacement::Overlay);
+ assert_eq!(plugin.link_handlers.len(), 1);
+ assert_eq!(plugin.link_handlers[0].id, "github-pr");
+ assert_eq!(plugin.link_handlers[0].action, "bootstrap");
+
+ let list = app.handle_api_request(Request {
+ id: "list".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list response: {list}");
+ };
+ assert_eq!(plugins.len(), 1);
+ assert_eq!(plugins[0].plugin_id, "example.worktree-bootstrap");
+
+ let unlink = app.handle_api_request(Request {
+ id: "unlink".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ assert!(matches!(
+ response_result(&unlink),
+ ResponseResult::PluginUnlinked {
+ plugin_id,
+ removed: true
+ } if plugin_id == "example.worktree-bootstrap"
+ ));
+
+ let list = app.handle_api_request(Request {
+ id: "list-empty".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list response: {list}");
+ };
+ assert!(plugins.is_empty());
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_invalid_github_source_path() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-invalid-source");
+ write_manifest(&root);
+
+ let response = app.handle_api_request(Request {
+ id: "link-invalid-source".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: Some(PluginSourceInfo {
+ kind: PluginSourceKind::Github,
+ owner: Some("ogulcancelik".into()),
+ repo: Some("herdr-plugin-examples".into()),
+ subdir: Some("worktree-bootstrap".into()),
+ requested_ref: None,
+ resolved_commit: Some("abc123".into()),
+ managed_path: Some(root.display().to_string()),
+ installed_unix_ms: Some(42),
+ }),
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "invalid_plugin_source");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_duplicate_action_ids() {
+ let root = unique_temp_path("plugin-duplicate-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.duplicate"
+name = "Duplicate"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["echo", "a"]
+
+[[actions]]
+id = "run"
+title = "Run again"
+command = ["echo", "b"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("duplicate_plugin_action_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_dotted_action_ids() {
+ let root = unique_temp_path("plugin-dotted-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.dotted-action"
+name = "Dotted Action"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "build.release"
+title = "Build"
+command = ["echo", "build"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("invalid_plugin_action_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_duplicate_pane_ids() {
+ let root = unique_temp_path("plugin-duplicate-pane");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.duplicate-pane"
+name = "Duplicate Pane"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[panes]]
+id = "ui"
+title = "UI"
+command = ["echo", "a"]
+
+[[panes]]
+id = "ui"
+title = "UI again"
+command = ["echo", "b"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("duplicate_plugin_pane_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn smoke_fixture_manifest_loads() {
+ let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true)
+ .expect("smoke fixture should load");
+ assert_eq!(plugin.plugin_id, "example.smoke");
+ assert_eq!(plugin.actions.len(), 1);
+ assert_eq!(plugin.events.len(), 1);
+ assert_eq!(plugin.panes.len(), 1);
+ assert!(plugin.warnings.is_empty());
+ }
+
+ #[test]
+ fn plugin_command_output_reader_caps_and_marks_truncation() {
+ let output = read_capped_plugin_output("abcdef".as_bytes(), 3);
+
+ assert_eq!(output, "abc\n[herdr truncated plugin output after 3 bytes]");
+ }
+
+ #[test]
+ fn plugin_enable_disable_updates_registry_state() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-enable-disable");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let disabled = app.handle_api_request(Request {
+ id: "disable".into(),
+ method: Method::PluginDisable(PluginSetEnabledParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ let ResponseResult::PluginDisabled { plugin } = response_result(&disabled) else {
+ panic!("expected disabled response: {disabled}");
+ };
+ assert!(!plugin.enabled);
+
+ let enabled = app.handle_api_request(Request {
+ id: "enable".into(),
+ method: Method::PluginEnable(PluginSetEnabledParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ let ResponseResult::PluginEnabled { plugin } = response_result(&enabled) else {
+ panic!("expected enabled response: {enabled}");
+ };
+ assert!(plugin.enabled);
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_pane_open_requires_installed_plugin() {
+ let mut app = test_app();
+ let response = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.missing".into(),
+ entrypoint: "ui".into(),
+ placement: Some(PluginPanePlacement::Split),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ 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"], "plugin_not_found");
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
+ let mut app = test_app();
+ let mut workspace = crate::workspace::Workspace::test_new("plugin-target");
+ workspace.custom_name = None;
+ let root_pane = workspace.tabs[0].root_pane;
+ let root_terminal = workspace.terminal_id(root_pane).cloned().unwrap();
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ app.state.terminals.get_mut(&root_terminal).unwrap().cwd = "/tmp".into();
+ let target_public_pane_id = app.public_pane_id(0, root_pane).unwrap();
+
+ let root = unique_temp_path("plugin-pane-open");
+ let capture = root.join("capture.txt");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.pane"
+name = "Pane Plugin"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_ENTRYPOINT_ID\" \"$HERDR_WORKSPACE_ID\" \"$HERDR_PANE_ID\" \"$HERDR_BIN_PATH\" \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Overlay),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::from([
+ ("HERDR_PLUGIN_ID".to_string(), "spoofed-plugin".to_string()),
+ (
+ "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(),
+ "spoofed-entrypoint".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_CONTEXT_JSON".to_string(),
+ "{\"spoofed\":true}".to_string(),
+ ),
+ (
+ "HERDR_BIN_PATH".to_string(),
+ "/tmp/spoofed-herdr".to_string(),
+ ),
+ ]),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+ assert_eq!(plugin_pane.plugin_id, "example.pane");
+ assert_eq!(plugin_pane.entrypoint, "board");
+ assert_eq!(plugin_pane.pane.label.as_deref(), Some("Plugin Board"));
+ let Some((_, opened_pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else {
+ panic!("opened pane id should parse");
+ };
+ assert!(app.state.plugin_panes.contains_key(&opened_pane_id));
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env");
+ let mut lines = text.lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(lines.next(), Some("example.pane"));
+ assert_eq!(lines.next(), Some("board"));
+ assert_eq!(lines.next(), Some(plugin_pane.pane.workspace_id.as_str()));
+ assert_eq!(lines.next(), Some(plugin_pane.pane.pane_id.as_str()));
+ let bin_path = lines.next().expect("bin path");
+ assert_ne!(bin_path, "/tmp/spoofed-herdr");
+ assert_eq!(
+ bin_path,
+ std::env::current_exe()
+ .expect("current exe should resolve")
+ .display()
+ .to_string()
+ );
+ let context: PluginInvocationContext =
+ serde_json::from_str(lines.next().expect("context json")).unwrap();
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(plugin_pane.pane.workspace_id.as_str())
+ );
+ assert_eq!(
+ context.focused_pane_id.as_deref(),
+ Some(target_public_pane_id.as_str())
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_injects_plugin_paths_and_protects_overrides() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-path-env")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ let root = unique_temp_path("plugin-pane-path-env");
+ let capture = root.join("capture.txt");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.path-env"
+name = "Path Env"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.path-env".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Overlay),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::from([
+ (
+ "HERDR_PLUGIN_ROOT".to_string(),
+ "/tmp/spoofed-root".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_CONFIG_DIR".to_string(),
+ "/tmp/spoofed-config".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_STATE_DIR".to_string(),
+ "/tmp/spoofed-state".to_string(),
+ ),
+ ]),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env");
+ let mut lines = text.lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::config_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.path-env"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::state_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.path-env"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_tab_emits_tab_created_before_pane_created() {
+ 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-tab")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+
+ let root = unique_temp_path("plugin-pane-tab-events");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.tab"
+name = "Tab Plugin"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+placement = "tab"
+command = ["sh", "-c", "sleep 1"]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open-tab".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.tab".into(),
+ entrypoint: "board".into(),
+ placement: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+
+ let events = event_hub
+ .events_after(0)
+ .into_iter()
+ .map(|(_, event)| event.event)
+ .collect::<Vec<_>>();
+ let tab_created = events
+ .iter()
+ .position(|event| *event == crate::api::schema::EventKind::TabCreated)
+ .expect("tab.created should be emitted");
+ let pane_created = events
+ .iter()
+ .position(|event| *event == crate::api::schema::EventKind::PaneCreated)
+ .expect("pane.created should be emitted");
+ assert!(tab_created < pane_created);
+
+ 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();
+ let root = unique_temp_path("plugin-action-list");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let list = app.handle_api_request(Request {
+ id: "list".into(),
+ method: Method::PluginActionList(PluginActionListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginActionList { actions } = response_result(&list) else {
+ panic!("expected plugin action list: {list}");
+ };
+ assert_eq!(actions.len(), 1);
+ assert_eq!(
+ actions[0].qualified_id(),
+ "example.worktree-bootstrap.bootstrap"
+ );
+ assert_eq!(actions[0].command, ["bun", "run", "bootstrap.ts"]);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: Some(PluginInvocationContext {
+ workspace_id: Some("1".into()),
+ workspace_label: None,
+ workspace_cwd: None,
+ worktree: None,
+ tab_id: None,
+ tab_label: None,
+ focused_pane_id: None,
+ focused_pane_cwd: None,
+ focused_pane_agent: None,
+ focused_pane_status: None,
+ selected_text: None,
+ invocation_source: Some("test".into()),
+ correlation_id: Some("external-correlation".into()),
+ clicked_url: None,
+ link_handler_id: None,
+ }),
+ }),
+ });
+ let ResponseResult::PluginActionInvoked {
+ action,
+ context,
+ log,
+ } = response_result(&invoke)
+ else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(
+ action.qualified_id(),
+ "example.worktree-bootstrap.bootstrap"
+ );
+ assert_eq!(action.command, ["bun", "run", "bootstrap.ts"]);
+ assert_eq!(log.plugin_id, "example.worktree-bootstrap");
+ assert_eq!(log.action_id.as_deref(), Some("bootstrap"));
+ assert_eq!(context.workspace_id.as_deref(), Some("1"));
+ assert_eq!(context.invocation_source.as_deref(), Some("test"));
+ assert_eq!(
+ context.correlation_id.as_deref(),
+ Some("external-correlation")
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn stale_registry_entries_are_visible_but_not_runnable() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-stale-registry");
+ write_manifest(&root);
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let _ = std::fs::remove_dir_all(&root);
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(vec![plugin], |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+ assert_eq!(reloaded.len(), 1);
+ assert!(reloaded[0].warnings.iter().any(|warning| warning
+ .starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX)));
+ app.state
+ .installed_plugins
+ .insert(reloaded[0].plugin_id.clone(), reloaded[0].clone());
+
+ let list = app.handle_api_request(Request {
+ id: "plugin-list".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list: {list}");
+ };
+ assert_eq!(plugins.len(), 1);
+
+ let actions = app.handle_api_request(Request {
+ id: "action-list".into(),
+ method: Method::PluginActionList(PluginActionListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginActionList { actions } = response_result(&actions) else {
+ panic!("expected action list: {actions}");
+ };
+ assert!(actions.is_empty());
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_manifest_unavailable");
+
+ let pane = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "board".into(),
+ placement: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&pane).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_manifest_unavailable");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn manifest_action_invoke_runs_command_and_captures_log() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-action-runner");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.runner"
+name = "Runner"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_ACTION_ID\""]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-runner".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.runner".into()),
+ action_id: "run".into(),
+ context: None,
+ }),
+ });
+ let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(log.status, PluginCommandStatus::Running);
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let logs = app.handle_api_request(Request {
+ id: "logs".into(),
+ method: Method::PluginLogList(PluginLogListParams {
+ plugin_id: Some("example.runner".into()),
+ limit: Some(10),
+ }),
+ });
+ let ResponseResult::PluginLogList { logs } = response_result(&logs) else {
+ panic!("expected plugin logs: {logs}");
+ };
+ let finished = logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ assert_eq!(finished.stdout.as_deref(), Some("run"));
+ assert_eq!(finished.exit_code, Some(0));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn manifest_action_invoke_injects_plugin_paths() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-action-path-env");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.action-paths"
+name = "Action Paths"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\""]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-runner".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.action-paths".into()),
+ action_id: "run".into(),
+ context: None,
+ }),
+ });
+ let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let logs = app.handle_api_request(Request {
+ id: "logs".into(),
+ method: Method::PluginLogList(PluginLogListParams {
+ plugin_id: Some("example.action-paths".into()),
+ limit: Some(10),
+ }),
+ });
+ let ResponseResult::PluginLogList { logs } = response_result(&logs) else {
+ panic!("expected plugin logs: {logs}");
+ };
+ let finished = logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ let mut lines = finished.stdout.as_deref().unwrap_or_default().lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::config_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.action-paths"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::state_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.action-paths"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[tokio::test]
+ async fn current_plugin_context_includes_selected_text_for_focused_pane() {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("plugin-selection");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ app.terminal_runtimes.insert(
+ terminal_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"),
+ );
+ app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None));
+
+ let context = app.current_plugin_context("selection-test");
+
+ assert_eq!(context.selected_text.as_deref(), Some("hello"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn event_hooks_use_event_target_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("active"),
+ crate::workspace::Workspace::test_new("event-target"),
+ ];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let active_workspace_id = app.public_workspace_id(0);
+ let target_workspace = app.workspace_info(1);
+
+ let root = unique_temp_path("plugin-event-context");
+ let capture = root.join("context.json");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.event-context"
+name = "Event Context"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorktreeCreated,
+ data: crate::api::schema::EventData::WorktreeCreated {
+ workspace: target_workspace.clone(),
+ worktree: crate::api::schema::WorktreeInfo {
+ path: "/tmp/repo".into(),
+ branch: Some("feature".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some(target_workspace.workspace_id.clone()),
+ label: "feature".into(),
+ },
+ },
+ });
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let context: PluginInvocationContext =
+ serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(target_workspace.workspace_id.as_str())
+ );
+ assert_ne!(
+ context.workspace_id.as_deref(),
+ Some(active_workspace_id.as_str())
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_command_limit_rejects_and_logs() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-command-limit");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+ app.state.plugin_commands_in_flight = MAX_PLUGIN_COMMANDS_IN_FLIGHT;
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-limit".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_command_limit_reached");
+ let log = app
+ .state
+ .plugin_command_logs
+ .last()
+ .expect("rejected command should be logged");
+ assert_eq!(log.status, PluginCommandStatus::Failed);
+ assert!(log
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("maximum concurrent plugin commands")));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn closed_event_context_uses_closed_target_ids() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("closed-events")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let active_pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let active_public_pane_id = app.public_pane_id(0, active_pane_id).unwrap();
+ let workspace_id = app.public_workspace_id(0);
+ let closed_tab_id = format!("{workspace_id}:t99");
+ let closed_pane_id = format!("{workspace_id}:p99");
+
+ let tab_context = app.plugin_context_for_event(
+ &crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabClosed,
+ data: crate::api::schema::EventData::TabClosed {
+ tab_id: closed_tab_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ },
+ "tab.closed",
+ );
+ assert_eq!(
+ tab_context.workspace_id.as_deref(),
+ Some(workspace_id.as_str())
+ );
+ assert_eq!(tab_context.tab_id.as_deref(), Some(closed_tab_id.as_str()));
+ assert_eq!(tab_context.focused_pane_id, None);
+
+ let pane_context = app.plugin_context_for_event(
+ &crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneClosed,
+ data: crate::api::schema::EventData::PaneClosed {
+ pane_id: closed_pane_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ },
+ "pane.closed",
+ );
+ assert_eq!(
+ pane_context.workspace_id.as_deref(),
+ Some(workspace_id.as_str())
+ );
+ assert_eq!(
+ pane_context.focused_pane_id.as_deref(),
+ Some(closed_pane_id.as_str())
+ );
+ assert_ne!(
+ pane_context.focused_pane_id.as_deref(),
+ Some(active_public_pane_id.as_str())
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn plugin_link_handler_invokes_action_with_clicked_url_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-handler")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let root = unique_temp_path("plugin-link-handler");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.links"
+name = "Links"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["sh", "-c", "printf '%s|%s' \"$HERDR_PLUGIN_LINK_HANDLER_ID\" \"$HERDR_PLUGIN_CLICKED_URL\""]
+
+[[link_handlers]]
+id = "github-issue"
+title = "Open GitHub issue"
+pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
+action = "open"
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let handled = app
+ .invoke_plugin_link_handler_for_url(
+ "https://github.com/ogulcancelik/herdr/issues/398",
+ pane_id,
+ )
+ .expect("link handler should invoke");
+ assert!(handled);
+ let log = app
+ .state
+ .plugin_command_logs
+ .last()
+ .expect("plugin command log should be recorded")
+ .clone();
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let finished = app
+ .state
+ .plugin_command_logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ assert_eq!(finished.action_id.as_deref(), Some("open"));
+ assert_eq!(
+ finished.stdout.as_deref(),
+ Some("github-issue|https://github.com/ogulcancelik/herdr/issues/398")
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_handlers_keep_manifest_order_for_overlapping_patterns() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-link-handler-order");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.link-order"
+name = "Link Order"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "specific"
+title = "Specific"
+command = ["true"]
+
+[[actions]]
+id = "generic"
+title = "Generic"
+command = ["true"]
+
+[[link_handlers]]
+id = "z-specific"
+title = "Specific GitHub issue"
+pattern = "^https://github\\.com/[^/]+/[^/]+/issues/[0-9]+$"
+action = "specific"
+
+[[link_handlers]]
+id = "a-generic"
+title = "Generic GitHub"
+pattern = "^https://github\\.com/"
+action = "generic"
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let (_plugin, handler) = app
+ .find_plugin_link_handler("https://github.com/ogulcancelik/herdr/issues/398")
+ .expect("handler should match");
+ assert_eq!(handler.id, "z-specific");
+ assert_eq!(handler.action, "specific");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_invalid_link_handler_pattern() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-link-handler-pattern");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.bad-links"
+name = "Bad Links"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["true"]
+
+[[link_handlers]]
+id = "bad"
+title = "Bad"
+pattern = "["
+action = "open"
+"#,
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ value["error"]["code"],
+ "invalid_plugin_link_handler_pattern"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_link_handler_unknown_action() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-link-handler-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.bad-link-action"
+name = "Bad Link Action"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["true"]
+
+[[link_handlers]]
+id = "github"
+title = "GitHub"
+pattern = "^https://github\\.com/"
+action = "missing"
+"#,
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "invalid_plugin_link_handler_action");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn manifest_action_invoke_builds_default_workspace_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ app.state.workspaces[0].identity_cwd = "/tmp/issue".into();
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].custom_name = Some("Plugin Work".into());
+ app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/repo/herdr-issue".into(),
+ is_linked_worktree: true,
+ });
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let pane_public = app.public_pane_id(0, pane_id).unwrap();
+ let tab_public = app.public_tab_id(0, 0).unwrap();
+ let workspace_public = app.public_workspace_id(0);
+ let _ = app.handle_pane_report_agent(
+ "report".into(),
+ crate::api::schema::PaneReportAgentParams {
+ pane_id: pane_public.clone(),
+ source: "test".into(),
+ agent: "codex".into(),
+ state: crate::api::schema::PaneAgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: None,
+ agent_session_id: None,
+ agent_session_path: None,
+ },
+ );
+
+ let root = unique_temp_path("plugin-action-context");
+ // write a manifest with a "show" action in pane context
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.context"
+name = "Context"
+version = "0.1.0"
+
+[[actions]]
+id = "show"
+title = "Show Context"
+contexts = ["pane"]
+command = ["show-ctx"]
+"#,
+ )
+ .unwrap();
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-context".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.context".into()),
+ action_id: "show".into(),
+ context: None,
+ }),
+ });
+
+ let ResponseResult::PluginActionInvoked { context, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(workspace_public.as_str())
+ );
+ assert_eq!(context.workspace_label.as_deref(), Some("Plugin Work"));
+ assert_eq!(context.workspace_cwd.as_deref(), Some("/tmp/issue"));
+ assert_eq!(context.tab_id.as_deref(), Some(tab_public.as_str()));
+ assert_eq!(context.tab_label.as_deref(), Some("1"));
+ assert_eq!(
+ context.focused_pane_id.as_deref(),
+ Some(pane_public.as_str())
+ );
+ assert_eq!(context.focused_pane_cwd.as_deref(), Some("/tmp/issue"));
+ assert_eq!(context.focused_pane_agent.as_deref(), Some("codex"));
+ assert_eq!(
+ context.focused_pane_status,
+ Some(crate::api::schema::AgentStatus::Working)
+ );
+ assert_eq!(context.invocation_source.as_deref(), Some("api"));
+ assert_eq!(context.correlation_id.as_deref(), Some("invoke-context"));
+ let worktree = context.worktree.as_ref().unwrap();
+ assert_eq!(worktree.repo_key, "repo-key");
+ assert_eq!(worktree.repo_name, "herdr");
+ assert_eq!(worktree.repo_root, "/repo/herdr");
+ assert_eq!(worktree.checkout_path, "/repo/herdr-issue");
+ assert!(worktree.is_linked_worktree);
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn manifest_action_invoke_returns_plugin_disabled_when_disabled() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-disabled");
+ write_manifest(&root);
+
+ // link as disabled
+ let link_result = app.handle_api_request(Request {
+ id: "link-disabled".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: false,
+ source: None,
+ }),
+ });
+ assert!(
+ link_result.contains("plugin_linked"),
+ "expected plugin_linked: {link_result}"
+ );
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-disabled".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_disabled");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ fn write_manifest_with_bad_event(root: &std::path::Path) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(
+ &manifest,
+ r#"
+id = "example.bad-event"
+name = "Bad Event Plugin"
+version = "0.1.0"
+
+[[events]]
+on = "worktree.craeted"
+command = ["sh", "-c", "echo hi"]
+
+[[events]]
+on = "pane.output_changed"
+command = ["sh", "-c", "echo too noisy"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "echo ok"]
+"#,
+ )
+ .unwrap();
+ manifest
+ }
+
+ #[test]
+ fn link_with_unknown_event_name_succeeds_with_warning() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-event");
+ write_manifest_with_bad_event(&root);
+
+ let link = app.handle_api_request(Request {
+ id: "link-bad-event".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin_linked: {link}");
+ };
+ assert_eq!(plugin.plugin_id, "example.bad-event");
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("worktree.craeted")),
+ "expected warning for misspelled event, got: {:?}",
+ plugin.warnings
+ );
+ // The correctly named event produces no extra warning
+ assert_eq!(
+ plugin
+ .warnings
+ .iter()
+ .filter(|w| w.contains("worktree.created"))
+ .count(),
+ 0
+ );
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("pane.output_changed")),
+ "expected warning for unemitted output-change hook, got: {:?}",
+ plugin.warnings
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn output_changed_event_hooks_do_not_run_even_if_event_is_emitted() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-output-hook");
+ write_manifest_with_bad_event(&root);
+ link_manifest(&mut app, &root);
+
+ app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneOutputChanged,
+ data: crate::api::schema::EventData::PaneOutputChanged {
+ pane_id: "w1-1".into(),
+ workspace_id: "w1".into(),
+ revision: 1,
+ },
+ });
+
+ assert!(
+ app.state.plugin_command_logs.is_empty(),
+ "pane.output_changed hooks should be warning-only until hook semantics exist"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn unlink_removes_plugin_pane_records_for_that_plugin() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-unlink-panes");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ // Manually insert plugin_pane records as if pane.open was called.
+ let pane_a = crate::layout::PaneId::from_raw(1001u32);
+ let pane_b = crate::layout::PaneId::from_raw(1002u32);
+ let pane_other = crate::layout::PaneId::from_raw(1003u32);
+ app.state.plugin_panes.insert(
+ pane_a,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "main".into(),
+ },
+ );
+ app.state.plugin_panes.insert(
+ pane_b,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "side".into(),
+ },
+ );
+ app.state.plugin_panes.insert(
+ pane_other,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "other.plugin".into(),
+ entrypoint: "other".into(),
+ },
+ );
+
+ let unlink = app.handle_api_request(Request {
+ id: "unlink-panes".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ assert!(matches!(
+ response_result(&unlink),
+ ResponseResult::PluginUnlinked { removed: true, .. }
+ ));
+
+ // plugin_panes records for the unlinked plugin are gone
+ assert!(!app.state.plugin_panes.contains_key(&pane_a));
+ assert!(!app.state.plugin_panes.contains_key(&pane_b));
+ // other plugin's pane record survives
+ assert!(app.state.plugin_panes.contains_key(&pane_other));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_pane_record_survives_pane_move() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-move")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+ app.state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "move".into(),
+ method: Method::PaneMove(crate::api::schema::PaneMoveParams {
+ pane_id: public_pane_id,
+ destination: crate::api::schema::PaneMoveDestination::NewTab {
+ workspace_id: None,
+ label: Some("moved".into()),
+ },
+ focus: true,
+ }),
+ });
+ let ResponseResult::PaneMove { move_result } = response_result(&response) else {
+ panic!("expected pane move: {response}");
+ };
+ assert!(app.state.plugin_panes.contains_key(&pane_id));
+
+ let focus = app.handle_api_request(Request {
+ id: "focus".into(),
+ method: Method::PluginPaneFocus(PluginPaneFocusParams {
+ pane_id: move_result.pane.pane_id.clone(),
+ }),
+ });
+ let ResponseResult::PluginPaneFocused { plugin_pane } = response_result(&focus) else {
+ panic!("expected plugin pane focus: {focus}");
+ };
+ assert_eq!(plugin_pane.plugin_id, "example.pane");
+ assert_eq!(plugin_pane.entrypoint, "board");
+ assert_eq!(plugin_pane.pane.pane_id, move_result.pane.pane_id);
+ }
+
+ #[test]
+ fn pane_exit_removes_plugin_pane_record() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-exit")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
+
+ app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id });
+
+ assert!(!app.state.plugin_panes.contains_key(&pane_id));
+ }
+
+ #[test]
+ fn registry_round_trip_via_explicit_path() {
+ let root = unique_temp_path("plugin-registry-rt");
+ write_manifest(&root);
+
+ let registry_dir = unique_temp_path("registry-dir");
+ std::fs::create_dir_all(&registry_dir).unwrap();
+ let registry_path = registry_dir.join("plugins.json");
+
+ // link via load_plugin_manifest + save_to_path (simulating what the App does)
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let plugins = vec![plugin.clone()];
+ crate::persist::plugin_registry::save_to_path(&registry_path, &plugins).unwrap();
+ assert!(registry_path.exists());
+
+ // load back and verify
+ let loaded = crate::persist::plugin_registry::load_from_path(&registry_path);
+ assert_eq!(loaded.len(), 1);
+ assert_eq!(loaded[0].plugin_id, "example.worktree-bootstrap");
+ assert_eq!(loaded[0].name, "Worktree Bootstrap");
+
+ // reload_manifests with real manifest still on disk → fresh parse succeeds
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(loaded, |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+ assert_eq!(reloaded.len(), 1);
+ assert!(reloaded[0].warnings.is_empty());
+ assert_eq!(reloaded[0].version, "0.1.0");
+
+ let _ = std::fs::remove_dir_all(root);
+ let _ = std::fs::remove_dir_all(registry_dir);
+ }
+
+ #[test]
+ fn reload_manifests_keeps_entry_with_warning_when_manifest_gone() {
+ let root = unique_temp_path("plugin-missing-manifest");
+ write_manifest(&root);
+
+ // load_plugin_manifest resolves the absolute path via canonicalize()
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let stored_manifest_path = plugin.manifest_path.clone();
+
+ // Now delete the manifest
+ let _ = std::fs::remove_dir_all(&root);
+
+ // Simulate registry load + reload
+ let entries = vec![plugin];
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+
+ assert_eq!(reloaded.len(), 1);
+ assert_eq!(reloaded[0].plugin_id, "example.worktree-bootstrap");
+ assert!(!reloaded[0].warnings.is_empty(), "expected load warning");
+ // The stored manifest_path is preserved so the entry is still identifiable
+ assert_eq!(reloaded[0].manifest_path, stored_manifest_path);
+ }
+
+ // ── Platform compatibility tests ─────────────────────────────────────────
+
+ #[test]
+ fn manifest_with_platforms_parses_correctly() {
+ let root = unique_temp_path("plugin-platforms");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.platforms"
+name = "Platforms"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["./run.sh"]
+
+[[actions]]
+id = "run-win"
+title = "Run Windows"
+platforms = ["windows"]
+command = ["run.bat"]
+"#,
+ )
+ .unwrap();
+
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ use crate::api::schema::PluginPlatform;
+ assert_eq!(
+ plugin.platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ // Action without own platforms inherits from plugin level
+ let run = plugin.actions.iter().find(|a| a.id == "run").unwrap();
+ assert!(run.platforms.is_none());
+ // Action with own platforms has them set
+ let run_win = plugin.actions.iter().find(|a| a.id == "run-win").unwrap();
+ assert_eq!(run_win.platforms, Some(vec![PluginPlatform::Windows]));
+ // No missing-platforms warning because platforms is declared
+ assert!(
+ plugin.warnings.is_empty(),
+ "expected no warnings: {:?}",
+ plugin.warnings
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn effective_platform_resolution_inherits_from_plugin() {
+ use crate::api::schema::PluginPlatform;
+ let plugin_platforms = Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]);
+ let no_override: Option<Vec<PluginPlatform>> = None;
+ let action_override = Some(vec![PluginPlatform::Windows]);
+
+ // No action-level platforms → inherit from plugin
+ assert_eq!(
+ effective_platforms(&no_override, &plugin_platforms),
+ &plugin_platforms
+ );
+ // Action-level platforms → use action's own list
+ assert_eq!(
+ effective_platforms(&action_override, &plugin_platforms),
+ &action_override
+ );
+ // Both None → None (undeclared)
+ assert_eq!(effective_platforms(&no_override, &no_override), &None);
+ }
+
+ #[test]
+ fn invoke_on_unsupported_platform_returns_error() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-reject");
+ std::fs::create_dir_all(&root).unwrap();
+
+ // Declare only platforms that are NOT the current build target so the
+ // invoke is guaranteed to be rejected regardless of which OS this runs on.
+ let excluded_platforms = if cfg!(target_os = "linux") {
+ r#"platforms = ["macos", "windows"]"#
+ } else if cfg!(target_os = "macos") {
+ r#"platforms = ["linux", "windows"]"#
+ } else {
+ r#"platforms = ["linux", "macos"]"#
+ };
+
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ format!(
+ r#"
+id = "example.reject"
+name = "Reject"
+version = "0.1.0"
+{excluded_platforms}
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#
+ ),
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(link.contains("plugin_linked"), "link failed: {link}");
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.reject".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(
+ value["error"]["code"], "platform_unsupported",
+ "expected platform_unsupported error: {invoke}"
+ );
+ assert!(
+ invoke.contains("example.reject.act"),
+ "error message should name the action: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn invoke_with_action_platform_override_uses_action_platforms() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-action-override");
+ std::fs::create_dir_all(&root).unwrap();
+
+ // Plugin declares all platforms; action declares only the non-current platforms.
+ let excluded_platforms = if cfg!(target_os = "linux") {
+ r#"platforms = ["macos", "windows"]"#
+ } else if cfg!(target_os = "macos") {
+ r#"platforms = ["linux", "windows"]"#
+ } else {
+ r#"platforms = ["linux", "macos"]"#
+ };
+
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ format!(
+ r#"
+id = "example.override"
+name = "Override"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "act"
+title = "Act"
+{excluded_platforms}
+command = ["act"]
+"#
+ ),
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(link.contains("plugin_linked"), "link failed: {link}");
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.override".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(
+ value["error"]["code"], "platform_unsupported",
+ "expected platform_unsupported for action override: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn invoke_with_undeclared_platforms_succeeds() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-undeclared");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.nodecl"
+name = "No Decl"
+version = "0.1.0"
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#,
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin_linked: {link}");
+ };
+ // Should get the missing-platforms warning
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("does not declare platforms")),
+ "expected missing-platforms warning: {:?}",
+ plugin.warnings
+ );
+
+ // Invoke should succeed regardless (local dev allowance)
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.nodecl".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ assert!(
+ invoke.contains("plugin_action_invoked"),
+ "expected success for undeclared platforms: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_with_invalid_platform_string_fails() {
+ let root = unique_temp_path("plugin-bad-platform");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.badplatform"
+name = "Bad Platform"
+version = "0.1.0"
+platforms = ["linux", "beos"]
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#,
+ )
+ .unwrap();
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(result.is_err(), "expected parse error for unknown platform");
+ let (_, msg) = result.unwrap_err();
+ assert!(
+ msg.contains("beos") || msg.contains("platform"),
+ "error message should mention the bad platform: {msg}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn registry_round_trip_preserves_platforms() {
+ let root = unique_temp_path("plugin-platform-rt");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.platform-rt"
+name = "Platform RT"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "act"
+title = "Act"
+platforms = ["windows"]
+command = ["act.exe"]
+"#,
+ )
+ .unwrap();
+
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ use crate::api::schema::PluginPlatform;
+ assert_eq!(
+ plugin.platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ assert_eq!(
+ plugin.actions[0].platforms,
+ Some(vec![PluginPlatform::Windows])
+ );
+
+ let registry_dir = unique_temp_path("platform-rt-registry");
+ std::fs::create_dir_all(&registry_dir).unwrap();
+ let registry_path = registry_dir.join("plugins.json");
+ crate::persist::plugin_registry::save_to_path(&registry_path, &[plugin]).unwrap();
+
+ let loaded = crate::persist::plugin_registry::load_from_path(&registry_path);
+ assert_eq!(
+ loaded[0].platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ assert_eq!(
+ loaded[0].actions[0].platforms,
+ Some(vec![PluginPlatform::Windows])
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ let _ = std::fs::remove_dir_all(registry_dir);
+ }
+}
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"
+ )
+}
diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs
new file mode 100644
index 00000000..c6955bb4
--- /dev/null
+++ b/src/app/api/plugins/runtime.rs
@@ -0,0 +1,271 @@
+use std::io::Read;
+use std::process::{Command, Stdio};
+
+use super::manifest::{effective_platforms, ensure_platform_supported};
+use super::plugin_manifest_available;
+use crate::api::schema::{
+ InstalledPluginInfo, PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext,
+};
+use crate::app::App;
+
+const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+pub(super) const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32;
+const PLUGIN_COMMAND_LOG_LIMIT: usize = 200;
+
+impl App {
+ pub(super) fn start_plugin_command(
+ &mut self,
+ plugin: &InstalledPluginInfo,
+ action_id: Option<String>,
+ event: Option<String>,
+ command: Vec<String>,
+ context: &PluginInvocationContext,
+ event_json: Option<String>,
+ ) -> Result<PluginCommandLogInfo, (&'static str, String)> {
+ let Some(program) = command.first().cloned() else {
+ return Err((
+ "invalid_plugin_command",
+ "command must not be empty".to_string(),
+ ));
+ };
+ let args = command.iter().skip(1).cloned().collect::<Vec<_>>();
+ let context_json = serde_json::to_string(context)
+ .map_err(|err| ("invalid_plugin_context", err.to_string()))?;
+ let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id);
+ self.state.next_plugin_command_log_id += 1;
+ let started_unix_ms = current_unix_ms();
+ let mut env = super::env::plugin_path_env(plugin);
+ env.extend([
+ (
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ ),
+ ("HERDR_ENV".to_string(), "1".to_string()),
+ ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()),
+ ("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(),
+ ));
+ }
+ if let Some(action_id) = action_id.as_ref() {
+ env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone()));
+ }
+ if let Some(event) = event.as_ref() {
+ env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone()));
+ }
+ if let Some(event_json) = event_json {
+ env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json));
+ }
+ if let Some(workspace_id) = context.workspace_id.as_ref() {
+ env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone()));
+ }
+ if let Some(tab_id) = context.tab_id.as_ref() {
+ env.push(("HERDR_TAB_ID".to_string(), tab_id.clone()));
+ }
+ if let Some(pane_id) = context.focused_pane_id.as_ref() {
+ env.push(("HERDR_PANE_ID".to_string(), pane_id.clone()));
+ }
+ if let Some(clicked_url) = context.clicked_url.as_ref() {
+ env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone()));
+ }
+ if let Some(link_handler_id) = context.link_handler_id.as_ref() {
+ env.push((
+ "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(),
+ link_handler_id.clone(),
+ ));
+ }
+ if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT {
+ let message = format!(
+ "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})"
+ );
+ let log = PluginCommandLogInfo {
+ log_id,
+ plugin_id: plugin.plugin_id.clone(),
+ action_id,
+ event,
+ command,
+ status: PluginCommandStatus::Failed,
+ started_unix_ms,
+ finished_unix_ms: Some(started_unix_ms),
+ exit_code: None,
+ stdout: Some(String::new()),
+ stderr: Some(String::new()),
+ error: Some(message.clone()),
+ };
+ self.push_plugin_command_log(log);
+ return Err(("plugin_command_limit_reached", message));
+ }
+ let plugin_root = std::path::PathBuf::from(&plugin.plugin_root);
+ let log = PluginCommandLogInfo {
+ log_id: log_id.clone(),
+ plugin_id: plugin.plugin_id.clone(),
+ action_id,
+ event,
+ command: command.clone(),
+ status: PluginCommandStatus::Running,
+ started_unix_ms,
+ finished_unix_ms: None,
+ exit_code: None,
+ stdout: None,
+ stderr: None,
+ error: None,
+ };
+ self.push_plugin_command_log(log.clone());
+ self.state.plugin_commands_in_flight += 1;
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let child = Command::new(&program)
+ .args(args)
+ .current_dir(plugin_root)
+ .envs(env)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn();
+ let finished = match child {
+ Ok(mut child) => {
+ let stdout = child.stdout.take();
+ let stderr = child.stderr.take();
+ let stdout_reader = stdout.map(|stdout| {
+ std::thread::spawn(move || {
+ read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES)
+ })
+ });
+ let stderr_reader = stderr.map(|stderr| {
+ std::thread::spawn(move || {
+ read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES)
+ })
+ });
+ match child.wait() {
+ Ok(status) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: status.code(),
+ stdout: stdout_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ stderr: stderr_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ error: None,
+ },
+ Err(err) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: None,
+ stdout: stdout_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ stderr: stderr_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ error: Some(err.to_string()),
+ },
+ }
+ }
+ Err(err) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: None,
+ stdout: String::new(),
+ stderr: String::new(),
+ error: Some(err.to_string()),
+ },
+ };
+ let _ = event_tx.blocking_send(finished);
+ });
+ Ok(log)
+ }
+
+ pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) {
+ let event_name = event.event.dot_name();
+ if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) {
+ return;
+ }
+ let plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin.enabled
+ && plugin_manifest_available(plugin)
+ && plugin.events.iter().any(|hook| hook.on == event_name)
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ if plugins.is_empty() {
+ return;
+ }
+ let event_json = serde_json::to_string(event).ok();
+ let context = self.plugin_context_for_event(event, event_name);
+ for plugin in plugins {
+ for hook in plugin.events.clone() {
+ if hook.on != event_name {
+ continue;
+ }
+ if ensure_platform_supported(
+ &effective_platforms(&hook.platforms, &plugin.platforms).clone(),
+ event_name,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let _ = self.start_plugin_command(
+ &plugin,
+ None,
+ Some(event_name.to_string()),
+ hook.command.clone(),
+ &context,
+ event_json.clone(),
+ );
+ }
+ }
+ }
+
+ fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) {
+ self.state.plugin_command_logs.push(log);
+ if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT {
+ let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT;
+ self.state.plugin_command_logs.drain(0..extra);
+ }
+ }
+}
+
+fn current_unix_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
+ .unwrap_or(0)
+}
+
+pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String {
+ let mut kept = Vec::with_capacity(cap.min(8192));
+ let mut buf = [0u8; 8192];
+ let mut truncated = false;
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ let remaining = cap.saturating_sub(kept.len());
+ if remaining > 0 {
+ kept.extend_from_slice(&buf[..n.min(remaining)]);
+ }
+ if n > remaining {
+ truncated = true;
+ }
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ let mut output = String::from_utf8_lossy(&kept).into_owned();
+ if truncated {
+ output.push_str(&format!(
+ "\n[herdr truncated plugin output after {cap} bytes]"
+ ));
+ }
+ output
+}
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
index 7c7ad9d7..c88a00d1 100644
--- a/src/app/api/tabs.rs
+++ b/src/app/api/tabs.rs
@@ -52,6 +52,7 @@ impl App {
cwd,
focus,
label,
+ env,
} = params;
let ws_idx = if let Some(workspace_id) = workspace_id {
let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
@@ -74,6 +75,10 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
+ let extra_env = match super::env::normalize_launch_env(env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
let result = self
.state
.workspaces
@@ -87,6 +92,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
)
});
match result {
@@ -195,6 +201,13 @@ impl App {
};
let workspace_id = self.public_workspace_id(ws_idx);
let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
+ let pane_ids = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .map(|tab| tab.layout.pane_ids())
+ .unwrap_or_default();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return tab_not_found(id, &target.tab_id);
};
@@ -212,6 +225,9 @@ impl App {
format!("tab {} could not be closed", target.tab_id),
);
}
+ for pane_id in pane_ids {
+ self.state.plugin_panes.remove(&pane_id);
+ }
self.state.remove_unattached_terminal_ids(terminal_ids);
self.shutdown_detached_terminal_runtimes();
self.schedule_session_save();
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index e1966adf..cdfd4ec5 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -51,7 +51,11 @@ impl App {
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx));
self.resolve_new_terminal_cwd(follow_cwd)
});
- match self.create_workspace_with_options(cwd, params.focus) {
+ let extra_env = match super::env::normalize_launch_env(params.env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ match self.create_workspace_with_launch_env(cwd, params.focus, extra_env) {
Ok(index) => {
if let Some(label) = params.label {
if let Some(workspace) = self.state.workspaces.get_mut(index) {
@@ -147,12 +151,30 @@ impl App {
return workspace_not_found(id, &target.workspace_id);
}
let workspace_id = self.public_workspace_id(index);
+ let workspace = self.workspace_info(index);
+ let pane_ids = self
+ .state
+ .workspaces
+ .get(index)
+ .map(|ws| {
+ ws.tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids())
+ .collect::<Vec<_>>()
+ })
+ .unwrap_or_default();
self.state.selected = index;
self.state.close_selected_workspace();
+ for pane_id in pane_ids {
+ self.state.plugin_panes.remove(&pane_id);
+ }
self.shutdown_detached_terminal_runtimes();
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed { workspace_id },
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace),
+ },
});
encode_success(id, ResponseResult::Ok {})
@@ -208,4 +230,37 @@ mod tests {
assert_eq!(app.state.request_remove_linked_worktree, None);
assert!(app.state.workspaces.is_empty());
}
+
+ #[test]
+ fn api_workspace_close_event_includes_final_worktree_snapshot() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = app_with_linked_worktree().state.workspaces;
+ let workspace_id = app.state.workspaces[0].id.clone();
+
+ let response = app.handle_workspace_close(
+ "req".into(),
+ WorkspaceTarget {
+ workspace_id: workspace_id.clone(),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceClosed {
+ workspace_id: closed_id,
+ workspace: Some(workspace),
+ } if closed_id == &workspace_id
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ )
+ }));
+ }
}
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index 573771e2..648b571a 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -148,6 +148,13 @@ impl App {
let worktree = self
.worktree_info_for_checkout(&source, ws_idx)
.expect("created worktree workspace should have worktree info");
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeCreated,
+ data: EventData::WorktreeCreated {
+ workspace: self.workspace_info(ws_idx),
+ worktree: worktree.clone(),
+ },
+ });
encode_success(
id,
ResponseResult::WorktreeCreated {
@@ -244,6 +251,15 @@ impl App {
}
let tab_idx = self.state.workspaces[ws_idx].active_tab;
+ let worktree = self.worktree_info_for_entry(&source, entry);
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeOpened,
+ data: EventData::WorktreeOpened {
+ workspace: self.workspace_info(ws_idx),
+ worktree: worktree.clone(),
+ already_open: already_open.is_some(),
+ },
+ });
encode_success(
id,
ResponseResult::WorktreeOpened {
@@ -254,7 +270,7 @@ impl App {
root_pane: self
.root_pane_info(ws_idx, tab_idx)
.expect("opened worktree workspace should have an active root pane"),
- worktree: self.worktree_info_for_entry(&source, entry),
+ worktree,
already_open: already_open.is_some(),
},
)
@@ -313,6 +329,8 @@ impl App {
&space.checkout_path,
params.force,
);
+ let workspace_snapshot = self.workspace_info(ws_idx);
+ let worktree = self.worktree_info_for_membership(&space, None);
if let Err(err) = crate::worktree::run_worktree_command(&command) {
let code = if !params.force && crate::worktree::is_dirty_worktree_remove_error(&err) {
"dirty_worktree_requires_force"
@@ -337,9 +355,18 @@ impl App {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: workspace_id.clone(),
+ workspace: Some(workspace_snapshot),
},
});
}
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeRemoved,
+ data: EventData::WorktreeRemoved {
+ workspace_id: workspace_id.clone(),
+ worktree,
+ forced: params.force,
+ },
+ });
encode_success(
id,
@@ -747,6 +774,25 @@ impl App {
})
}
+ fn worktree_info_for_membership(
+ &self,
+ membership: &crate::workspace::WorktreeSpaceMembership,
+ open_workspace_id: Option<String>,
+ ) -> WorktreeInfo {
+ let branch = crate::workspace::git_branch(&membership.checkout_path);
+ let is_detached = branch.is_none();
+ WorktreeInfo {
+ path: membership.checkout_path.display().to_string(),
+ branch,
+ is_bare: false,
+ is_detached,
+ is_prunable: false,
+ is_linked_worktree: membership.is_linked_worktree,
+ open_workspace_id,
+ label: membership.label.clone(),
+ }
+ }
+
fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> {
let canonical_checkout = crate::worktree::canonical_or_original(checkout_path);
let checkout_key = canonical_checkout.display().to_string();
@@ -777,7 +823,7 @@ impl App {
})
}
- fn emit_workspace_open_events(&self, ws_idx: usize) {
+ fn emit_workspace_open_events(&mut self, ws_idx: usize) {
let workspace_info = self.workspace_info(ws_idx);
let Some(tab) = self.tab_info(ws_idx, 0) else {
return;
@@ -801,7 +847,7 @@ impl App {
});
}
- fn emit_workspace_updated(&self, ws_idx: usize) {
+ fn emit_workspace_updated(&mut self, ws_idx: usize) {
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceUpdated,
data: EventData::WorkspaceUpdated {
@@ -940,7 +986,14 @@ mod tests {
async fn api_worktree_create_opens_workspace_and_marks_membership() {
let repo = create_committed_repo("api-worktree-create-repo");
let worktree_root = unique_temp_path("api-worktree-create-root");
- let mut app = app_with_parent(&repo);
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.clone();
+ app.state.workspaces = vec![parent];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
app.state.worktree_directory = worktree_root.clone();
let response = app.handle_api_request(Request {
@@ -980,6 +1033,17 @@ mod tests {
.is_linked_worktree
);
assert!(workspace.worktree.unwrap().is_linked_worktree);
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeCreated {
+ workspace: event_workspace,
+ worktree: event_worktree,
+ } if event_workspace.workspace_id == workspace.workspace_id
+ && event_worktree.branch.as_deref() == Some("worktree/api-create")
+ && event_worktree.is_linked_worktree
+ )
+ }));
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
@@ -1140,7 +1204,11 @@ mod tests {
let subdir = checkout.join("nested");
std::fs::create_dir_all(&subdir).unwrap();
- let mut app = app_with_parent(&repo);
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.clone();
+ app.state.workspaces = vec![parent];
let mut child = Workspace::test_new("child");
child.identity_cwd = subdir;
app.state.workspaces.push(child);
@@ -1175,6 +1243,19 @@ mod tests {
.unwrap()
.is_linked_worktree
);
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeOpened {
+ workspace: event_workspace,
+ worktree: event_worktree,
+ already_open,
+ } if event_workspace.workspace_id == workspace.workspace_id
+ && event_worktree.branch.as_deref() == Some("worktree/api-open")
+ && event_worktree.is_linked_worktree
+ && *already_open
+ )
+ }));
let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -1578,7 +1659,28 @@ mod tests {
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
matches!(
&event.data,
- EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id
+ EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace),
+ } if workspace_id == &child_id
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ )
+ }));
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeRemoved {
+ workspace_id,
+ worktree,
+ forced,
+ } if workspace_id == &child_id
+ && worktree.branch.as_deref() == Some("worktree/api-remove-event")
+ && worktree.is_linked_worktree
+ && worktree.open_workspace_id.is_none()
+ && !forced
)
}));