refactor: split plugin api module

This commit is contained in:
Ogulcan Celik
2026-06-13 21:46:20 +03:00
parent c38ddd749b
commit cd9105291c
5 changed files with 1488 additions and 1448 deletions
+379
View File
@@ -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,
}
}
+539
View File
@@ -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())
}
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
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.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_ENTRYPOINT_ID"
| "HERDR_PLUGIN_CONTEXT_JSON"
| "HERDR_BIN_PATH"
)
}
+270
View File
@@ -0,0 +1,270 @@
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 = vec![
(
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
}