use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::common::AgentStatus; use super::common::SplitDirection; use super::panes::PaneInfo; use super::workspaces::WorkspaceWorktreeInfo; use crate::popup_size::PopupSize; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginLinkParams { pub path: String, #[serde(default = "super::common::default_true")] pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct PluginListParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub plugin_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginUnlinkParams { pub plugin_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginSetEnabledParams { pub plugin_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct InstalledPluginInfo { pub plugin_id: String, pub name: String, pub version: String, #[serde(default)] pub min_herdr_version: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub manifest_path: String, pub plugin_root: String, pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub build: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub events: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub panes: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub link_handlers: Vec, #[serde(default)] pub source: PluginSourceInfo, /// Warnings collected at link time or on registry load (e.g. unknown event names, /// missing manifest file). Non-fatal — the entry is kept and surfaced by plugin.list. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginSourceInfo { #[serde(default)] pub kind: PluginSourceKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub subdir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_commit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub managed_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub installed_unix_ms: Option, } impl Default for PluginSourceInfo { fn default() -> Self { Self { kind: PluginSourceKind::Local, owner: None, repo: None, subdir: None, requested_ref: None, resolved_commit: None, managed_path: None, installed_unix_ms: None, } } } #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default, )] #[serde(rename_all = "snake_case")] pub enum PluginSourceKind { #[default] Local, Github, } pub(crate) fn plugin_managed_path_component(value: &str) -> String { let slug = readable_plugin_path_slug(value); let hash = short_plugin_id_hash_for_path_component(value); format!("{slug}-{hash}") } fn readable_plugin_path_slug(value: &str) -> String { let mut slug = value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { '-' } }) .collect::(); while slug.contains("--") { slug = slug.replace("--", "-"); } let slug = slug .trim_matches(|ch| matches!(ch, '-' | '_' | '.')) .to_string(); let slug = if slug.is_empty() { "plugin".to_string() } else { slug.chars().take(80).collect() }; if has_windows_reserved_stem_for_path_component(&slug) { slug.replace('.', "-") } else { slug } } pub(crate) fn short_plugin_id_hash_for_path_component(value: &str) -> String { use sha2::{Digest, Sha256}; let digest = Sha256::digest(value.as_bytes()); let mut hash = String::with_capacity(12); for byte in &digest[..6] { use std::fmt::Write as _; let _ = write!(hash, "{byte:02x}"); } hash } pub(crate) fn has_windows_reserved_stem_for_path_component(value: &str) -> bool { let stem = value.split('.').next().unwrap_or(value); matches!( stem.to_ascii_uppercase().as_str(), "CON" | "PRN" | "AUX" | "NUL" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" ) } #[cfg(test)] mod tests { use super::*; #[test] fn plugin_managed_path_component_is_windows_safe_and_collision_free() { let dotdot = plugin_managed_path_component(".."); assert_ne!(dotdot, "."); assert_ne!(dotdot, ".."); assert!(dotdot.starts_with("plugin-")); assert_ne!( plugin_managed_path_component("a:b"), plugin_managed_path_component("a_b") ); assert_ne!(plugin_managed_path_component("con"), "con"); assert!(!plugin_managed_path_component("example.").ends_with('.')); assert!(plugin_managed_path_component("con.example").starts_with("con-example-")); assert!(plugin_managed_path_component("aux.plugin").starts_with("aux-plugin-")); assert!(plugin_managed_path_component("nul.x").starts_with("nul-x-")); assert!(plugin_managed_path_component("com1.tool").starts_with("com1-tool-")); } #[test] fn plugin_managed_path_component_keeps_readable_slug() { let component = plugin_managed_path_component("example.worktree-bootstrap"); assert!(component.starts_with("example.worktree-bootstrap-")); assert!(component.len() <= "example.worktree-bootstrap-".len() + 12); } #[test] fn plugin_managed_path_component_hash_distinguishes_same_slug_shape() { assert_ne!( plugin_managed_path_component("example:a"), plugin_managed_path_component("example/a") ); } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginManifestBuild { #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, pub command: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginManifestAction { pub id: String, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub contexts: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, pub command: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginManifestEventHook { pub on: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, pub command: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginManifestPane { pub id: String, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, #[serde(default)] pub placement: PluginPanePlacement, #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub height: Option, pub command: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginManifestLinkHandler { pub id: String, pub title: String, pub pattern: String, pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct PluginActionListParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub plugin_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct PluginLogListParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub plugin_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginActionInvokeParams { pub action_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub plugin_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginCommandLogInfo { pub log_id: String, pub plugin_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub action_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub event: Option, pub command: Vec, pub status: PluginCommandStatus, pub started_unix_ms: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub finished_unix_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub exit_code: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stdout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stderr: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PluginCommandStatus { Running, Succeeded, Failed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PluginPlatform { Linux, Macos, Windows, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PluginActionContext { Global, Workspace, Tab, Pane, Selection, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginInvocationContext { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub focused_pane_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub focused_pane_cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub focused_pane_agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub focused_pane_status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub selected_text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub invocation_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub correlation_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub clicked_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub link_handler_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginActionInfo { pub plugin_id: String, pub action_id: String, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub contexts: Vec, pub command: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub platforms: Option>, } impl PluginActionInfo { pub fn qualified_id(&self) -> String { format!("{}.{}", self.plugin_id, self.action_id) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginPaneOpenParams { pub plugin_id: String, pub entrypoint: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub placement: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub height: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_pane_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub direction: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default)] pub focus: bool, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub env: HashMap, } #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default, )] #[serde(rename_all = "snake_case")] pub enum PluginPanePlacement { #[default] Overlay, Popup, Split, Tab, Zoomed, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginPaneFocusParams { pub pane_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginPaneCloseParams { pub pane_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginPaneInfo { pub plugin_id: String, pub entrypoint: String, pub pane: PaneInfo, }