mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-03 00:00:23 +00:00
refactor: split plugin api module
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user