diff options
Diffstat (limited to 'src/app/api/plugins')
| -rw-r--r-- | src/app/api/plugins/context.rs | 379 | ||||
| -rw-r--r-- | src/app/api/plugins/env.rs | 19 | ||||
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 539 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 2704 | ||||
| -rw-r--r-- | src/app/api/plugins/panes.rs | 284 | ||||
| -rw-r--r-- | src/app/api/plugins/runtime.rs | 271 |
6 files changed, 4196 insertions, 0 deletions
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(¶ms.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(¶ms.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(), ¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(®istry_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(®istry_path, &plugins).unwrap(); + assert!(registry_path.exists()); + + // load back and verify + let loaded = crate::persist::plugin_registry::load_from_path(®istry_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(®istry_dir).unwrap(); + let registry_path = registry_dir.join("plugins.json"); + crate::persist::plugin_registry::save_to_path(®istry_path, &[plugin]).unwrap(); + + let loaded = crate::persist::plugin_registry::load_from_path(®istry_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 +} |
