diff options
Diffstat (limited to 'src')
63 files changed, 11347 insertions, 1702 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 4832ac56..62247aa7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -35,6 +35,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::TabFocus(_) | Method::TabRename(_) | Method::TabClose(_) + | Method::LayoutApply(_) | Method::AgentRename(_) | Method::AgentFocus(_) | Method::AgentStart(_) @@ -51,6 +52,10 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneClearAgentAuthority(_) | Method::PaneReleaseAgent(_) | Method::PaneClose(_) + | Method::PluginActionInvoke(_) + | Method::PluginPaneOpen(_) + | Method::PluginPaneFocus(_) + | Method::PluginPaneClose(_) ) } diff --git a/src/api/schema.rs b/src/api/schema.rs index 512c030f..b2d6f62e 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -1,10 +1,28 @@ -use std::collections::HashMap; - use serde::{Deserialize, Serialize}; +pub mod agents; +pub mod common; +pub mod events; +pub mod integrations; pub mod panes; - +pub mod plugins; +pub mod response; +pub mod server; +pub mod tabs; +pub mod workspaces; +pub mod worktrees; + +pub use agents::*; +pub use common::*; +pub use events::*; +pub use integrations::*; pub use panes::*; +pub use plugins::*; +pub use response::*; +pub use server::*; +pub use tabs::*; +pub use workspaces::*; +pub use worktrees::*; fn is_false(value: &bool) -> bool { !*value @@ -19,6 +37,9 @@ pub struct Request { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "method", content = "params")] +// Request enums are short-lived wire values; keeping variants direct preserves +// the simple serde shape and avoids boxing churn across every caller. +#[allow(clippy::large_enum_variant)] pub enum Method { #[serde(rename = "ping")] Ping(PingParams), @@ -34,6 +55,10 @@ pub enum Method { ServerReloadAgentManifests(EmptyParams), #[serde(rename = "notification.show")] NotificationShow(NotificationShowParams), + #[serde(rename = "client.window_title.set")] + ClientWindowTitleSet(ClientWindowTitleSetParams), + #[serde(rename = "client.window_title.clear")] + ClientWindowTitleClear(EmptyParams), #[serde(rename = "workspace.create")] WorkspaceCreate(WorkspaceCreateParams), #[serde(rename = "workspace.list")] @@ -92,6 +117,12 @@ pub enum Method { PaneZoom(PaneZoomParams), #[serde(rename = "pane.layout")] PaneLayout(PaneLayoutParams), + #[serde(rename = "pane.process_info")] + PaneProcessInfo(PaneProcessInfoParams), + #[serde(rename = "layout.export")] + LayoutExport(LayoutExportParams), + #[serde(rename = "layout.apply")] + LayoutApply(LayoutApplyParams), #[serde(rename = "pane.neighbor")] PaneNeighbor(PaneNeighborParams), #[serde(rename = "pane.edges")] @@ -102,6 +133,8 @@ pub enum Method { PaneResize(PaneResizeParams), #[serde(rename = "pane.list")] PaneList(PaneListParams), + #[serde(rename = "pane.current")] + PaneCurrent(PaneCurrentParams), #[serde(rename = "pane.get")] PaneGet(PaneTarget), #[serde(rename = "pane.rename")] @@ -136,1478 +169,29 @@ pub enum Method { IntegrationInstall(IntegrationInstallParams), #[serde(rename = "integration.uninstall")] IntegrationUninstall(IntegrationUninstallParams), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct EmptyParams {} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PingParams {} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NotificationShowParams { - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub body: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub position: Option<crate::config::ToastHerdrPosition>, - #[serde(default, skip_serializing_if = "NotificationShowSound::is_none")] - pub sound: NotificationShowSound, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum NotificationShowSound { - #[default] - None, - Done, - Request, -} - -impl NotificationShowSound { - pub fn is_none(&self) -> bool { - matches!(self, Self::None) - } - - pub fn to_sound(self) -> Option<crate::sound::Sound> { - match self { - Self::None => None, - Self::Done => Some(crate::sound::Sound::Done), - Self::Request => Some(crate::sound::Sound::Request), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NotificationShowReason { - Shown, - Disabled, - RateLimited, - NoForegroundClient, - Busy, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceTarget { - pub workspace_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneTarget { - pub pane_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TabTarget { - pub tab_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceCreateParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceRenameParams { - pub workspace_id: String, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct WorktreeListParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct WorktreeCreateParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - #[serde(default)] - pub focus: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct WorktreeOpenParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - #[serde(default)] - pub focus: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorktreeRemoveParams { - pub workspace_id: String, - #[serde(default)] - pub force: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TabCreateParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct TabListParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TabRenameParams { - pub tab_id: String, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentTarget { - pub target: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentReadParams { - pub target: String, - pub source: ReadSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lines: Option<u32>, - #[serde(default)] - pub format: ReadFormat, - #[serde(default = "default_true")] - pub strip_ansi: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentSendParams { - pub target: String, - pub text: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentRenameParams { - pub target: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentStartParams { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tab_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub split: Option<SplitDirection>, - #[serde(default)] - pub focus: bool, - pub argv: Vec<String>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServerLiveHandoffParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub import_exe: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_protocol: Option<u32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_version: Option<String>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReadSource { - Visible, - Recent, - RecentUnwrapped, - Detection, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum ReadFormat { - #[default] - Text, - Ansi, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EventsSubscribeParams { - pub subscriptions: Vec<Subscription>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Subscription { - #[serde(rename = "workspace.created")] - WorkspaceCreated {}, - #[serde(rename = "workspace.updated")] - WorkspaceUpdated {}, - #[serde(rename = "workspace.renamed")] - WorkspaceRenamed {}, - #[serde(rename = "workspace.closed")] - WorkspaceClosed {}, - #[serde(rename = "workspace.focused")] - WorkspaceFocused {}, - #[serde(rename = "tab.created")] - TabCreated {}, - #[serde(rename = "tab.closed")] - TabClosed {}, - #[serde(rename = "tab.focused")] - TabFocused {}, - #[serde(rename = "tab.renamed")] - TabRenamed {}, - #[serde(rename = "pane.created")] - PaneCreated {}, - #[serde(rename = "pane.closed")] - PaneClosed {}, - #[serde(rename = "pane.focused")] - PaneFocused {}, - #[serde(rename = "pane.moved")] - PaneMoved {}, - #[serde(rename = "pane.exited")] - PaneExited {}, - #[serde(rename = "pane.agent_detected")] - PaneAgentDetected {}, - #[serde(rename = "pane.output_matched")] - PaneOutputMatched { - pane_id: String, - source: ReadSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - lines: Option<u32>, - r#match: OutputMatch, - #[serde(default = "default_true")] - strip_ansi: bool, - }, - #[serde(rename = "pane.agent_status_changed")] - PaneAgentStatusChanged { - pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - agent_status: Option<AgentStatus>, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EventsWaitParams { - pub match_event: EventMatch, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneWaitForOutputParams { - pub pane_id: String, - pub source: ReadSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lines: Option<u32>, - pub r#match: OutputMatch, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option<u64>, - #[serde(default = "default_true")] - pub strip_ansi: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntegrationInstallParams { - pub target: IntegrationTarget, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntegrationUninstallParams { - pub target: IntegrationTarget, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IntegrationTarget { - Pi, - Omp, - Claude, - Codex, - Copilot, - Droid, - Kimi, - Opencode, - Kilo, - Hermes, - Qodercli, - Cursor, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum OutputMatch { - Substring { value: String }, - Regex { value: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "event", rename_all = "snake_case")] -pub enum EventMatch { - WorkspaceCreated { - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_id: Option<String>, - }, - WorkspaceUpdated { - workspace_id: String, - }, - WorkspaceClosed { - workspace_id: String, - }, - WorkspaceRenamed { - workspace_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - label: Option<String>, - }, - WorkspaceFocused { - workspace_id: String, - }, - TabCreated { - #[serde(default, skip_serializing_if = "Option::is_none")] - tab_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_id: Option<String>, - }, - TabClosed { - tab_id: String, - }, - TabRenamed { - tab_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - label: Option<String>, - }, - TabFocused { - tab_id: String, - }, - PaneCreated { - #[serde(default, skip_serializing_if = "Option::is_none")] - pane_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_id: Option<String>, - }, - PaneClosed { - pane_id: String, - }, - PaneFocused { - pane_id: String, - }, - PaneMoved { - pane_id: String, - }, - PaneOutputChanged { - pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - min_revision: Option<u64>, - }, - PaneExited { - pane_id: String, - }, - PaneAgentDetected { - pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - agent: Option<String>, - }, - PaneAgentStatusChanged { - pane_id: String, - agent_status: AgentStatus, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EventKind { - WorkspaceCreated, - WorkspaceUpdated, - WorkspaceClosed, - WorkspaceRenamed, - WorkspaceFocused, - TabCreated, - TabClosed, - TabRenamed, - TabFocused, - PaneCreated, - PaneClosed, - PaneFocused, - PaneMoved, - PaneOutputChanged, - PaneExited, - PaneAgentDetected, - PaneAgentStatusChanged, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SuccessResponse { - pub id: String, - pub result: ResponseResult, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorResponse { - pub id: String, - pub error: ErrorBody, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorBody { - pub code: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServerCapabilities { - pub live_handoff: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ResponseResult { - Pong { - version: String, - protocol: u32, - #[serde(default)] - capabilities: Option<ServerCapabilities>, - }, - WorkspaceInfo { - workspace: WorkspaceInfo, - }, - WorkspaceCreated { - workspace: WorkspaceInfo, - tab: TabInfo, - root_pane: PaneInfo, - }, - WorkspaceList { - workspaces: Vec<WorkspaceInfo>, - }, - WorktreeList { - source: WorktreeSourceInfo, - worktrees: Vec<WorktreeInfo>, - }, - WorktreeCreated { - workspace: WorkspaceInfo, - tab: TabInfo, - root_pane: PaneInfo, - worktree: WorktreeInfo, - }, - WorktreeOpened { - workspace: WorkspaceInfo, - tab: TabInfo, - root_pane: PaneInfo, - worktree: WorktreeInfo, - already_open: bool, - }, - WorktreeRemoved { - workspace_id: String, - path: String, - forced: bool, - }, - TabInfo { - tab: TabInfo, - }, - TabCreated { - tab: TabInfo, - root_pane: PaneInfo, - }, - TabList { - tabs: Vec<TabInfo>, - }, - AgentInfo { - agent: AgentInfo, - }, - AgentStarted { - agent: AgentInfo, - argv: Vec<String>, - }, - AgentList { - agents: Vec<AgentInfo>, - }, - PaneInfo { - pane: PaneInfo, - }, - PaneList { - panes: Vec<PaneInfo>, - }, - PaneSwap { - swap: PaneSwapResult, - }, - PaneMove { - move_result: PaneMoveResult, - }, - PaneZoom { - zoom: PaneZoomResult, - }, - PaneLayout { - layout: PaneLayoutSnapshot, - }, - PaneNeighbor { - neighbor: PaneNeighborResult, - }, - PaneEdges { - edges: PaneEdgesResult, - }, - PaneFocusDirection { - focus: PaneFocusDirectionResult, - }, - PaneResize { - resize: PaneResizeResult, - }, - PaneRead { - read: PaneReadResult, - }, - AgentExplain { - explain: serde_json::Value, - }, - SubscriptionStarted {}, - WaitMatched { - event: EventEnvelope, - }, - OutputMatched { - pane_id: String, - revision: u64, - matched_line: Option<String>, - read: PaneReadResult, - }, - NotificationShow { - shown: bool, - reason: NotificationShowReason, - }, - IntegrationInstall { - target: IntegrationTarget, - details: IntegrationInstallResult, - }, - IntegrationUninstall { - target: IntegrationTarget, - details: IntegrationUninstallResult, - }, - AgentManifestReload { - manifests: Vec<AgentManifestInfo>, - }, - AgentManifestStatus { - #[serde(default, skip_serializing_if = "Option::is_none")] - last_check_unix: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - last_result: Option<String>, - manifests: Vec<AgentManifestInfo>, - }, - ConfigReload { - status: crate::config::ConfigReloadStatus, - diagnostics: Vec<String>, - }, - Ok {}, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentManifestInfo { - pub agent: String, - pub source: String, - pub source_kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_version: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cached_remote_version: Option<String>, - pub local_override_shadowing_remote: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_update_result: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_update_error: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_last_checked_unix: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warning: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceInfo { - pub workspace_id: String, - pub number: usize, - pub label: String, - pub focused: bool, - pub pane_count: usize, - pub tab_count: usize, - pub active_tab_id: String, - pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worktree: Option<WorkspaceWorktreeInfo>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceWorktreeInfo { - pub repo_key: String, - pub repo_name: String, - pub repo_root: String, - pub checkout_path: String, - pub is_linked_worktree: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorktreeSourceInfo { - pub repo_key: String, - pub repo_name: String, - pub repo_root: String, - pub source_checkout_path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_workspace_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorktreeInfo { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option<String>, - pub is_bare: bool, - pub is_detached: bool, - pub is_prunable: bool, - pub is_linked_worktree: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub open_workspace_id: Option<String>, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TabInfo { - pub tab_id: String, - pub workspace_id: String, - pub number: usize, - pub label: String, - pub focused: bool, - pub pane_count: usize, - pub agent_status: AgentStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentInfo { - pub terminal_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_agent: Option<String>, - pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "is_false")] - pub screen_detection_skipped: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub state_labels: HashMap<String, String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session: Option<AgentSessionInfo>, - pub workspace_id: String, - pub tab_id: String, - pub pane_id: String, - pub focused: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub foreground_cwd: Option<String>, - pub revision: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AgentSessionInfo { - pub source: String, - pub agent: String, - pub kind: crate::agent_resume::AgentSessionRefKind, - pub value: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntegrationInstallResult { - pub messages: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntegrationUninstallResult { - pub messages: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EventEnvelope { - pub event: EventKind, - pub data: EventData, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SubscriptionEventKind { - #[serde(rename = "pane.output_matched")] - PaneOutputMatched, - #[serde(rename = "pane.agent_status_changed")] - PaneAgentStatusChanged, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SubscriptionEventEnvelope { - pub event: SubscriptionEventKind, - pub data: SubscriptionEventData, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum SubscriptionEventData { - PaneOutputMatched(PaneOutputMatchedEvent), - PaneAgentStatusChanged(PaneAgentStatusChangedEvent), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneOutputMatchedEvent { - pub pane_id: String, - pub matched_line: String, - pub read: PaneReadResult, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneAgentStatusChangedEvent { - pub pane_id: String, - pub workspace_id: String, - pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_agent: Option<String>, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub state_labels: HashMap<String, String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum EventData { - WorkspaceCreated { - workspace: WorkspaceInfo, - }, - WorkspaceUpdated { - workspace: WorkspaceInfo, - }, - WorkspaceClosed { - workspace_id: String, - }, - WorkspaceRenamed { - workspace_id: String, - label: String, - }, - WorkspaceFocused { - workspace_id: String, - }, - TabCreated { - tab: TabInfo, - }, - TabClosed { - tab_id: String, - workspace_id: String, - }, - TabRenamed { - tab_id: String, - workspace_id: String, - label: String, - }, - TabFocused { - tab_id: String, - workspace_id: String, - }, - PaneCreated { - pane: PaneInfo, - }, - PaneClosed { - pane_id: String, - workspace_id: String, - }, - PaneFocused { - pane_id: String, - workspace_id: String, - }, - PaneMoved { - previous_pane_id: String, - previous_workspace_id: String, - previous_tab_id: String, - pane: Box<PaneInfo>, - #[serde(default, skip_serializing_if = "Option::is_none")] - created_workspace: Option<WorkspaceInfo>, - #[serde(default, skip_serializing_if = "Option::is_none")] - created_tab: Option<TabInfo>, - #[serde(default, skip_serializing_if = "Option::is_none")] - closed_workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - closed_tab_id: Option<String>, - }, - PaneOutputChanged { - pane_id: String, - workspace_id: String, - revision: u64, - }, - PaneExited { - pane_id: String, - workspace_id: String, - }, - PaneAgentDetected { - pane_id: String, - workspace_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - agent: Option<String>, - }, - PaneAgentStatusChanged { - pane_id: String, - workspace_id: String, - agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - custom_status: Option<String>, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - state_labels: HashMap<String, String>, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PaneAgentState { - Idle, - Working, - Blocked, - Unknown, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentStatus { - Idle, - Working, - Blocked, - Done, - Unknown, -} - -fn default_true() -> bool { - true + #[serde(rename = "plugin.link")] + PluginLink(PluginLinkParams), + #[serde(rename = "plugin.list")] + PluginList(PluginListParams), + #[serde(rename = "plugin.unlink")] + PluginUnlink(PluginUnlinkParams), + #[serde(rename = "plugin.enable")] + PluginEnable(PluginSetEnabledParams), + #[serde(rename = "plugin.disable")] + PluginDisable(PluginSetEnabledParams), + #[serde(rename = "plugin.action.list")] + PluginActionList(PluginActionListParams), + #[serde(rename = "plugin.action.invoke")] + PluginActionInvoke(PluginActionInvokeParams), + #[serde(rename = "plugin.log.list")] + PluginLogList(PluginLogListParams), + #[serde(rename = "plugin.pane.open")] + PluginPaneOpen(PluginPaneOpenParams), + #[serde(rename = "plugin.pane.focus")] + PluginPaneFocus(PluginPaneFocusParams), + #[serde(rename = "plugin.pane.close")] + PluginPaneClose(PluginPaneCloseParams), } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn request_round_trips_for_pane_read() { - let request = Request { - id: "req_1".into(), - method: Method::PaneRead(PaneReadParams { - pane_id: "p_1".into(), - source: ReadSource::Recent, - lines: Some(80), - format: ReadFormat::Text, - strip_ansi: true, - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_pane_report_agent() { - let request = Request { - id: "req_hook".into(), - method: Method::PaneReportAgent(PaneReportAgentParams { - pane_id: "w1:p1".into(), - source: "herdr:pi".into(), - agent: "pi".into(), - state: PaneAgentState::Working, - message: Some("thinking".into()), - custom_status: Some("indexing".into()), - seq: Some(42), - agent_session_id: Some("pi-session".into()), - agent_session_path: Some("/tmp/pi-session.jsonl".into()), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_pane_report_agent_session() { - let request = Request { - id: "req_session".into(), - method: Method::PaneReportAgentSession(PaneReportAgentSessionParams { - pane_id: "w1:p1".into(), - source: "herdr:claude".into(), - agent: "claude".into(), - seq: Some(42), - agent_session_id: Some("claude-session".into()), - agent_session_path: None, - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_pane_report_metadata() { - let request = Request { - id: "req_metadata".into(), - method: Method::PaneReportMetadata(PaneReportMetadataParams { - pane_id: "w1:p1".into(), - source: "user:claude-title".into(), - agent: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: Some("Refactor auth".into()), - display_agent: Some("Claude auth".into()), - custom_status: Some("refactor auth".into()), - state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), - clear_title: false, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - seq: Some(42), - ttl_ms: Some(3_600_000), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_pane_clear_agent_authority() { - let request = Request { - id: "req_clear".into(), - method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams { - pane_id: "w1:p1".into(), - source: Some("herdr:pi".into()), - seq: Some(42), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_pane_release_agent() { - let request = Request { - id: "req_release".into(), - method: Method::PaneReleaseAgent(PaneReleaseAgentParams { - pane_id: "w1:p1".into(), - source: "herdr:pi".into(), - agent: "pi".into(), - seq: Some(42), - }), - }; - - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_uses_dot_method_names() { - let request = Request { - id: "req_1".into(), - method: Method::WorkspaceCreate(WorkspaceCreateParams { - cwd: Some("/tmp".into()), - focus: true, - label: Some("api".into()), - }), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "workspace.create"); - } - - #[test] - fn request_round_trips_for_server_stop() { - let request = Request { - id: "req_stop".into(), - method: Method::ServerStop(EmptyParams::default()), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "server.stop"); - let restored: Request = serde_json::from_value(json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_server_reload_config() { - let request = Request { - id: "req_reload".into(), - method: Method::ServerReloadConfig(EmptyParams::default()), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "server.reload_config"); - let restored: Request = serde_json::from_value(json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_server_reload_agent_manifests() { - let request = Request { - id: "req_reload_agent_manifests".into(), - method: Method::ServerReloadAgentManifests(EmptyParams::default()), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "server.reload_agent_manifests"); - let restored: Request = serde_json::from_value(json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_server_agent_manifests() { - let request = Request { - id: "req_agent_manifests".into(), - method: Method::ServerAgentManifests(EmptyParams::default()), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "server.agent_manifests"); - let restored: Request = serde_json::from_value(json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn request_round_trips_for_agent_explain() { - let request = Request { - id: "req_agent_explain".into(), - method: Method::AgentExplain(AgentTarget { - target: "agent-1".into(), - }), - }; - - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["method"], "agent.explain"); - let restored: Request = serde_json::from_value(json).unwrap(); - assert_eq!(restored, request); - } - - #[test] - fn notification_show_request_parses() { - let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#; - let request: Request = serde_json::from_str(json).unwrap(); - let Method::NotificationShow(params) = request.method else { - panic!("wrong method parsed"); - }; - assert_eq!(params.title, "build failed"); - assert_eq!(params.body.as_deref(), Some("api workspace")); - assert_eq!( - params.position, - Some(crate::config::ToastHerdrPosition::TopLeft) - ); - assert_eq!(params.sound, NotificationShowSound::Request); - } - - #[test] - fn notification_show_sound_defaults_to_none() { - let json = - r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed"}}"#; - let request: Request = serde_json::from_str(json).unwrap(); - let Method::NotificationShow(params) = request.method else { - panic!("wrong method parsed"); - }; - - assert_eq!(params.sound, NotificationShowSound::None); - } - - #[test] - fn unknown_method_is_rejected() { - let json = r#"{"id":"req_1","method":"nope","params":{}}"#; - let err = serde_json::from_str::<Request>(json) - .unwrap_err() - .to_string(); - assert!(err.contains("unknown variant")); - } - - #[test] - fn missing_required_params_are_rejected() { - let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#; - let err = serde_json::from_str::<Request>(json) - .unwrap_err() - .to_string(); - assert!(err.contains("text")); - } - - #[test] - fn pane_send_input_defaults_to_empty_text_and_keys() { - let json = r#" - { - "id": "req_1", - "method": "pane.send_input", - "params": { - "pane_id": "p_1" - } - } - "#; - - let request: Request = serde_json::from_str(json).unwrap(); - let Method::PaneSendInput(params) = request.method else { - panic!("wrong method parsed"); - }; - assert_eq!(params.pane_id, "p_1"); - assert!(params.text.is_empty()); - assert!(params.keys.is_empty()); - } - - #[test] - fn pane_wait_for_output_defaults_strip_ansi_to_true() { - let json = r#" - { - "id": "req_1", - "method": "pane.wait_for_output", - "params": { - "pane_id": "p_1", - "source": "recent", - "match": { "type": "substring", "value": "ready" } - } - } - "#; - - let request: Request = serde_json::from_str(json).unwrap(); - let Method::PaneWaitForOutput(params) = request.method else { - panic!("wrong method parsed"); - }; - assert!(params.strip_ansi); - } - - #[test] - fn pane_read_defaults_to_text_format() { - let json = r#" - { - "id": "req_1", - "method": "pane.read", - "params": { - "pane_id": "p_1", - "source": "visible" - } - } - "#; - - let request: Request = serde_json::from_str(json).unwrap(); - let Method::PaneRead(params) = request.method else { - panic!("wrong method parsed"); - }; - assert_eq!(params.format, ReadFormat::Text); - } - - #[test] - fn event_envelope_round_trips() { - let event = EventEnvelope { - event: EventKind::PaneOutputChanged, - data: EventData::PaneOutputChanged { - pane_id: "w1:p1".into(), - workspace_id: "w1".into(), - revision: 42, - }, - }; - - let json = serde_json::to_string(&event).unwrap(); - let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, event); - } - - #[test] - fn subscribe_request_parses_parameterized_subscriptions() { - let json = r#" - { - "id": "sub_1", - "method": "events.subscribe", - "params": { - "subscriptions": [ - { - "type": "pane.output_matched", - "pane_id": "p_1_1", - "source": "recent", - "lines": 200, - "match": { "type": "substring", "value": "auth: received" } - }, - { - "type": "pane.agent_status_changed", - "pane_id": "p_1_1", - "agent_status": "done" - } - ] - } - } - "#; - - let request: Request = serde_json::from_str(json).unwrap(); - let Method::EventsSubscribe(params) = request.method else { - panic!("wrong method parsed"); - }; - assert_eq!(params.subscriptions.len(), 2); - assert!(matches!( - ¶ms.subscriptions[0], - Subscription::PaneOutputMatched { - pane_id, - source: ReadSource::Recent, - lines: Some(200), - r#match: OutputMatch::Substring { value }, - strip_ansi: true, - } if pane_id == "p_1_1" && value == "auth: received" - )); - assert!(matches!( - ¶ms.subscriptions[1], - Subscription::PaneAgentStatusChanged { - pane_id, - agent_status: Some(AgentStatus::Done), - } if pane_id == "p_1_1" - )); - } - - #[test] - fn subscription_event_envelope_round_trips() { - let event = SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneOutputMatched, - data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { - pane_id: "w1:p1".into(), - matched_line: "auth: received".into(), - read: PaneReadResult { - pane_id: "w1:p1".into(), - workspace_id: "w1".into(), - tab_id: "w1:t1".into(), - source: ReadSource::Recent, - format: ReadFormat::Text, - text: "auth: received\n".into(), - revision: 0, - truncated: false, - }, - }), - }; - - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"event\":\"pane.output_matched\"")); - let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, event); - } - - #[test] - fn success_response_round_trips() { - let response = SuccessResponse { - id: "req_1".into(), - result: ResponseResult::Pong { - version: "0.1.2".into(), - protocol: 6, - capabilities: Some(ServerCapabilities { live_handoff: true }), - }, - }; - - let json = serde_json::to_string(&response).unwrap(); - let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, response); - } - - #[test] - fn worktree_request_and_response_round_trip() { - let request = Request { - id: "req_worktree".into(), - method: Method::WorktreeCreate(WorktreeCreateParams { - workspace_id: Some("1".into()), - branch: Some("worktree/api".into()), - base: Some("HEAD".into()), - focus: true, - ..WorktreeCreateParams::default() - }), - }; - let json = serde_json::to_string(&request).unwrap(); - let restored: Request = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, request); - - let response = SuccessResponse { - id: "req_worktree".into(), - result: ResponseResult::WorktreeCreated { - workspace: WorkspaceInfo { - workspace_id: "w1".into(), - number: 2, - label: "herdr".into(), - focused: true, - pane_count: 1, - tab_count: 1, - active_tab_id: "w1:t1".into(), - agent_status: AgentStatus::Unknown, - worktree: Some(WorkspaceWorktreeInfo { - repo_key: "/repo/herdr/.git".into(), - repo_name: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/worktrees/herdr/worktree-api".into(), - is_linked_worktree: true, - }), - }, - tab: TabInfo { - tab_id: "w1:t1".into(), - workspace_id: "w1".into(), - number: 1, - label: "herdr".into(), - focused: true, - pane_count: 1, - agent_status: AgentStatus::Unknown, - }, - root_pane: PaneInfo { - pane_id: "w1:p1".into(), - terminal_id: "term_1".into(), - workspace_id: "w1".into(), - tab_id: "w1:t1".into(), - focused: true, - cwd: Some("/worktrees/herdr/worktree-api".into()), - foreground_cwd: None, - label: None, - agent: None, - title: None, - display_agent: None, - agent_status: AgentStatus::Unknown, - custom_status: None, - state_labels: HashMap::new(), - agent_session: None, - revision: 0, - }, - worktree: WorktreeInfo { - path: "/worktrees/herdr/worktree-api".into(), - branch: Some("worktree/api".into()), - is_bare: false, - is_detached: false, - is_prunable: false, - is_linked_worktree: true, - open_workspace_id: Some("w1".into()), - label: "herdr".into(), - }, - }, - }; - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"type\":\"worktree_created\"")); - assert!(json.contains("\"worktree\"")); - let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, response); - } - - #[test] - fn create_response_round_trips_with_root_pane() { - let response = SuccessResponse { - id: "req_2".into(), - result: ResponseResult::TabCreated { - tab: TabInfo { - tab_id: "w1:t2".into(), - workspace_id: "w1".into(), - number: 2, - label: "review".into(), - focused: false, - pane_count: 1, - agent_status: AgentStatus::Unknown, - }, - root_pane: PaneInfo { - pane_id: "w1:p3".into(), - terminal_id: "term_example".into(), - workspace_id: "w1".into(), - tab_id: "w1:t2".into(), - focused: false, - cwd: Some("/tmp/review".into()), - foreground_cwd: None, - label: None, - agent: None, - title: None, - display_agent: None, - agent_status: AgentStatus::Unknown, - custom_status: None, - state_labels: HashMap::new(), - agent_session: None, - revision: 0, - }, - }, - }; - - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"type\":\"tab_created\"")); - assert!(json.contains("\"root_pane\"")); - let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, response); - } - - #[test] - fn error_response_round_trips() { - let response = ErrorResponse { - id: "req_1".into(), - error: ErrorBody { - code: "pane_not_found".into(), - message: "pane p_1 not found".into(), - }, - }; - - let json = serde_json::to_string(&response).unwrap(); - let restored: ErrorResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, response); - } - - #[test] - fn event_wait_parses_typed_match() { - let json = r#" - { - "id": "req_9", - "method": "events.wait", - "params": { - "match_event": { - "event": "pane_agent_status_changed", - "pane_id": "p_1", - "agent_status": "done" - }, - "timeout_ms": 30000 - } - } - "#; - - let request: Request = serde_json::from_str(json).unwrap(); - let Method::EventsWait(params) = request.method else { - panic!("wrong method parsed"); - }; - assert_eq!( - params.match_event, - EventMatch::PaneAgentStatusChanged { - pane_id: "p_1".into(), - agent_status: AgentStatus::Done, - } - ); - } -} +mod tests; diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs new file mode 100644 index 00000000..644211d4 --- /dev/null +++ b/src/api/schema/agents.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::{AgentStatus, ReadFormat, ReadSource, SplitDirection}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentReadParams { + pub target: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + #[serde(default)] + pub format: ReadFormat, + #[serde(default = "super::common::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSendParams { + pub target: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentRenameParams { + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentStartParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub split: Option<SplitDirection>, + #[serde(default)] + pub focus: bool, + pub argv: Vec<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentInfo { + pub terminal_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "super::is_false")] + pub screen_detection_skipped: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option<AgentSessionInfo>, + pub workspace_id: String, + pub tab_id: String, + pub pane_id: String, + pub focused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option<String>, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSessionInfo { + pub source: String, + pub agent: String, + pub kind: crate::agent_resume::AgentSessionRefKind, + pub value: String, +} diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs new file mode 100644 index 00000000..cb423683 --- /dev/null +++ b/src/api/schema/common.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct EmptyParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTarget { + pub workspace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneTarget { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabTarget { + pub tab_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentTarget { + pub target: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientWindowTitleSetParams { + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Right, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadSource { + Visible, + Recent, + RecentUnwrapped, + Detection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ReadFormat { + #[default] + Text, + Ansi, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationShowParams { + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option<crate::config::ToastHerdrPosition>, + #[serde(default, skip_serializing_if = "NotificationShowSound::is_none")] + pub sound: NotificationShowSound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum NotificationShowSound { + #[default] + None, + Done, + Request, +} + +impl NotificationShowSound { + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + pub fn to_sound(self) -> Option<crate::sound::Sound> { + match self { + Self::None => None, + Self::Done => Some(crate::sound::Sound::Done), + Self::Request => Some(crate::sound::Sound::Request), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationShowReason { + Shown, + Disabled, + RateLimited, + NoForegroundClient, + Busy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientWindowTitleReason { + Set, + Cleared, + NoForegroundClient, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneAgentState { + Idle, + Working, + Blocked, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Idle, + Working, + Blocked, + Done, + Unknown, +} + +pub(crate) fn default_true() -> bool { + true +} diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs new file mode 100644 index 00000000..353f75b9 --- /dev/null +++ b/src/api/schema/events.rs @@ -0,0 +1,474 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::{AgentStatus, ReadSource}; +use super::panes::{PaneInfo, PaneReadResult}; +use super::tabs::TabInfo; +use super::workspaces::WorkspaceInfo; +use super::worktrees::WorktreeInfo; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsSubscribeParams { + pub subscriptions: Vec<Subscription>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Subscription { + #[serde(rename = "workspace.created")] + WorkspaceCreated {}, + #[serde(rename = "workspace.updated")] + WorkspaceUpdated {}, + #[serde(rename = "workspace.renamed")] + WorkspaceRenamed {}, + #[serde(rename = "workspace.closed")] + WorkspaceClosed {}, + #[serde(rename = "workspace.focused")] + WorkspaceFocused {}, + #[serde(rename = "worktree.created")] + WorktreeCreated {}, + #[serde(rename = "worktree.opened")] + WorktreeOpened {}, + #[serde(rename = "worktree.removed")] + WorktreeRemoved {}, + #[serde(rename = "tab.created")] + TabCreated {}, + #[serde(rename = "tab.closed")] + TabClosed {}, + #[serde(rename = "tab.focused")] + TabFocused {}, + #[serde(rename = "tab.renamed")] + TabRenamed {}, + #[serde(rename = "pane.created")] + PaneCreated {}, + #[serde(rename = "pane.closed")] + PaneClosed {}, + #[serde(rename = "pane.focused")] + PaneFocused {}, + #[serde(rename = "pane.moved")] + PaneMoved {}, + #[serde(rename = "pane.exited")] + PaneExited {}, + #[serde(rename = "pane.agent_detected")] + PaneAgentDetected {}, + #[serde(rename = "pane.output_matched")] + PaneOutputMatched { + pane_id: String, + source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + lines: Option<u32>, + r#match: OutputMatch, + #[serde(default = "super::common::default_true")] + strip_ansi: bool, + }, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_status: Option<AgentStatus>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsWaitParams { + pub match_event: EventMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneWaitForOutputParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + pub r#match: OutputMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, + #[serde(default = "super::common::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutputMatch { + Substring { value: String }, + Regex { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EventMatch { + WorkspaceCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + WorkspaceUpdated { + workspace_id: String, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + WorkspaceFocused { + workspace_id: String, + }, + TabCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + TabClosed { + tab_id: String, + }, + TabRenamed { + tab_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + TabFocused { + tab_id: String, + }, + PaneCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + PaneClosed { + pane_id: String, + }, + PaneFocused { + pane_id: String, + }, + PaneMoved { + pane_id: String, + }, + PaneOutputChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_revision: Option<u64>, + }, + PaneExited { + pane_id: String, + }, + PaneAgentDetected { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStatusChanged { + pane_id: String, + agent_status: AgentStatus, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + WorkspaceCreated, + WorkspaceUpdated, + WorkspaceClosed, + WorkspaceRenamed, + WorkspaceFocused, + WorktreeCreated, + WorktreeOpened, + WorktreeRemoved, + TabCreated, + TabClosed, + TabRenamed, + TabFocused, + PaneCreated, + PaneClosed, + PaneFocused, + PaneMoved, + PaneOutputChanged, + PaneExited, + PaneAgentDetected, + PaneAgentStatusChanged, +} + +impl EventKind { + pub fn dot_name(self) -> &'static str { + match self { + EventKind::WorkspaceCreated => "workspace.created", + EventKind::WorkspaceUpdated => "workspace.updated", + EventKind::WorkspaceClosed => "workspace.closed", + EventKind::WorkspaceRenamed => "workspace.renamed", + EventKind::WorkspaceFocused => "workspace.focused", + EventKind::WorktreeCreated => "worktree.created", + EventKind::WorktreeOpened => "worktree.opened", + EventKind::WorktreeRemoved => "worktree.removed", + EventKind::TabCreated => "tab.created", + EventKind::TabClosed => "tab.closed", + EventKind::TabRenamed => "tab.renamed", + EventKind::TabFocused => "tab.focused", + EventKind::PaneCreated => "pane.created", + EventKind::PaneClosed => "pane.closed", + EventKind::PaneFocused => "pane.focused", + EventKind::PaneMoved => "pane.moved", + EventKind::PaneOutputChanged => "pane.output_changed", + EventKind::PaneExited => "pane.exited", + EventKind::PaneAgentDetected => "pane.agent_detected", + EventKind::PaneAgentStatusChanged => "pane.agent_status_changed", + } + } +} + +#[cfg(test)] +pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneOutputChanged, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +#[cfg(test)] +pub fn known_event_names() -> Vec<&'static str> { + KNOWN_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +/// Event names that manifest `[[events]] on` hooks can reference. This is +/// intentionally narrower than `EventKind` until high-volume output-change hook +/// semantics are implemented. +pub fn plugin_hook_event_names() -> Vec<&'static str> { + PLUGIN_HOOK_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +#[cfg(test)] +mod known_event_name_tests { + use super::*; + + #[test] + fn known_event_names_stay_in_sync_with_event_kind() { + let mut from_kind = KNOWN_EVENT_KINDS + .iter() + .map(|kind| kind.dot_name()) + .collect::<Vec<_>>(); + from_kind.sort_unstable(); + let mut known = known_event_names(); + known.sort_unstable(); + assert_eq!( + from_kind, known, + "known_event_names() out of sync with EventKind" + ); + } + + #[test] + fn plugin_hook_event_names_exclude_unemitted_output_change() { + let names = plugin_hook_event_names(); + assert!(!names.contains(&"pane.output_changed")); + assert!(names.contains(&"pane.moved")); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub event: EventKind, + pub data: EventData, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SubscriptionEventKind { + #[serde(rename = "pane.output_matched")] + PaneOutputMatched, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscriptionEventEnvelope { + pub event: SubscriptionEventKind, + pub data: SubscriptionEventData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SubscriptionEventData { + PaneOutputMatched(PaneOutputMatchedEvent), + PaneAgentStatusChanged(PaneAgentStatusChangedEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneOutputMatchedEvent { + pub pane_id: String, + pub matched_line: String, + pub read: PaneReadResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentStatusChangedEvent { + pub pane_id: String, + pub workspace_id: String, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventData { + WorkspaceCreated { + workspace: WorkspaceInfo, + }, + WorkspaceUpdated { + workspace: WorkspaceInfo, + }, + WorkspaceClosed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option<WorkspaceInfo>, + }, + WorkspaceRenamed { + workspace_id: String, + label: String, + }, + WorkspaceFocused { + workspace_id: String, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + worktree: WorktreeInfo, + forced: bool, + }, + TabCreated { + tab: TabInfo, + }, + TabClosed { + tab_id: String, + workspace_id: String, + }, + TabRenamed { + tab_id: String, + workspace_id: String, + label: String, + }, + TabFocused { + tab_id: String, + workspace_id: String, + }, + PaneCreated { + pane: PaneInfo, + }, + PaneClosed { + pane_id: String, + workspace_id: String, + }, + PaneFocused { + pane_id: String, + workspace_id: String, + }, + PaneMoved { + previous_pane_id: String, + previous_workspace_id: String, + previous_tab_id: String, + pane: Box<PaneInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_workspace: Option<WorkspaceInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_tab: Option<TabInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_tab_id: Option<String>, + }, + PaneOutputChanged { + pane_id: String, + workspace_id: String, + revision: u64, + }, + PaneExited { + pane_id: String, + workspace_id: String, + }, + PaneAgentDetected { + pane_id: String, + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStatusChanged { + pane_id: String, + workspace_id: String, + agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + state_labels: HashMap<String, String>, + }, +} diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs new file mode 100644 index 00000000..49b27f96 --- /dev/null +++ b/src/api/schema/integrations.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationInstallParams { + pub target: IntegrationTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationUninstallParams { + pub target: IntegrationTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IntegrationTarget { + Pi, + Omp, + Claude, + Codex, + Copilot, + Droid, + Kimi, + Opencode, + Kilo, + Hermes, + Qodercli, + Cursor, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationInstallResult { + pub messages: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationUninstallResult { + pub messages: Vec<String>, +} diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index de330993..638829b0 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::{AgentSessionInfo, AgentStatus, PaneAgentState, ReadFormat, ReadSource}; +use super::agents::AgentSessionInfo; +use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneSplitParams { @@ -17,13 +18,8 @@ pub struct PaneSplitParams { pub cwd: Option<String>, #[serde(default)] pub focus: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SplitDirection { - Right, - Down, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -103,6 +99,71 @@ pub struct PaneLayoutParams { pub pane_id: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneProcessInfoParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LayoutExportParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutApplyParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option<String>, + #[serde(default)] + pub focus: bool, + pub root: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutDescription { + pub workspace_id: String, + pub tab_id: String, + pub zoomed: bool, + pub focused_pane_id: String, + pub root: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LayoutNode { + Pane { + #[serde(flatten)] + pane: LayoutPane, + }, + Split { + direction: SplitDirection, + ratio: f32, + first: Box<LayoutNode>, + second: Box<LayoutNode>, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct LayoutPane { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option<Vec<String>>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneNeighborParams { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -138,6 +199,12 @@ pub struct PaneListParams { pub workspace_id: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneCurrentParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller_pane_id: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneRenameParams { pub pane_id: String, @@ -286,6 +353,33 @@ pub struct PaneInfo { pub revision: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneProcessInfo { + pub pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell_pid: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_process_group_id: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreground_processes: Vec<PaneProcessInfoProcess>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneProcessInfoProcess { + pub pid: u32, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argv0: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argv: Option<Vec<String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cmdline: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneSwapResult { pub changed: bool, diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs new file mode 100644 index 00000000..535733ec --- /dev/null +++ b/src/api/schema/plugins.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; +use super::common::SplitDirection; +use super::panes::PaneInfo; +use super::workspaces::WorkspaceWorktreeInfo; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +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<PluginSourceInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginUnlinkParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSetEnabledParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPluginInfo { + pub plugin_id: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + pub manifest_path: String, + pub plugin_root: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub build: Vec<PluginManifestBuild>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<PluginManifestAction>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec<PluginManifestEventHook>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub panes: Vec<PluginManifestPane>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub link_handlers: Vec<PluginManifestLinkHandler>, + #[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<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSourceInfo { + #[serde(default)] + pub kind: PluginSourceKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdir: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_ref: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_commit: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_unix_ms: Option<u64>, +} + +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, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginSourceKind { + #[default] + Local, + Github, +} + +pub(crate) fn plugin_managed_path_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestBuild { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestAction { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec<PluginActionContext>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestEventHook { + pub on: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestPane { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + #[serde(default)] + pub placement: PluginPanePlacement, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +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<Vec<PluginPlatform>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginActionListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginLogListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option<usize>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginActionInvokeParams { + pub action_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option<PluginInvocationContext>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginCommandLogInfo { + pub log_id: String, + pub plugin_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option<String>, + pub command: Vec<String>, + pub status: PluginCommandStatus, + pub started_unix_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_unix_ms: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option<i32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdout: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stderr: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginCommandStatus { + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginPlatform { + Linux, + Macos, + Windows, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginActionContext { + Global, + Workspace, + Tab, + Pane, + Selection, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginInvocationContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option<WorkspaceWorktreeInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_status: Option<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_text: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invocation_source: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clicked_url: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_handler_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +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<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec<PluginActionContext>, + pub command: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, +} + +impl PluginActionInfo { + pub fn qualified_id(&self) -> String { + format!("{}.{}", self.plugin_id, self.action_id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneOpenParams { + pub plugin_id: String, + pub entrypoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placement: Option<PluginPanePlacement>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option<SplitDirection>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginPanePlacement { + #[default] + Overlay, + Split, + Tab, + Zoomed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneFocusParams { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneCloseParams { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneInfo { + pub plugin_id: String, + pub entrypoint: String, + pub pane: PaneInfo, +} diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs new file mode 100644 index 00000000..51ea0d36 --- /dev/null +++ b/src/api/schema/response.rs @@ -0,0 +1,248 @@ +use serde::{Deserialize, Serialize}; + +use super::agents::AgentInfo; +use super::common::{ClientWindowTitleReason, NotificationShowReason}; +use super::events::EventEnvelope; +use super::integrations::{ + IntegrationInstallResult, IntegrationTarget, IntegrationUninstallResult, +}; +use super::panes::{ + LayoutDescription, PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot, + PaneMoveResult, PaneNeighborResult, PaneProcessInfo, PaneReadResult, PaneResizeResult, + PaneSwapResult, PaneZoomResult, +}; +use super::plugins::{ + InstalledPluginInfo, PluginActionInfo, PluginCommandLogInfo, PluginInvocationContext, + PluginPaneInfo, +}; +use super::server::ServerCapabilities; +use super::tabs::TabInfo; +use super::workspaces::WorkspaceInfo; +use super::worktrees::{WorktreeInfo, WorktreeSourceInfo}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SuccessResponse { + pub id: String, + pub result: ResponseResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponse { + pub id: String, + pub error: ErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseResult { + Pong { + version: String, + protocol: u32, + #[serde(default)] + capabilities: Option<ServerCapabilities>, + }, + WorkspaceInfo { + workspace: WorkspaceInfo, + }, + WorkspaceCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + }, + WorkspaceList { + workspaces: Vec<WorkspaceInfo>, + }, + WorktreeList { + source: WorktreeSourceInfo, + worktrees: Vec<WorktreeInfo>, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + path: String, + forced: bool, + }, + TabInfo { + tab: TabInfo, + }, + TabCreated { + tab: TabInfo, + root_pane: PaneInfo, + }, + TabList { + tabs: Vec<TabInfo>, + }, + AgentInfo { + agent: AgentInfo, + }, + AgentStarted { + agent: AgentInfo, + argv: Vec<String>, + }, + AgentList { + agents: Vec<AgentInfo>, + }, + PaneInfo { + pane: PaneInfo, + }, + PaneList { + panes: Vec<PaneInfo>, + }, + PaneCurrent { + pane: PaneInfo, + }, + PaneSwap { + swap: PaneSwapResult, + }, + PaneMove { + move_result: PaneMoveResult, + }, + PaneZoom { + zoom: PaneZoomResult, + }, + PaneLayout { + layout: PaneLayoutSnapshot, + }, + PaneProcessInfo { + process_info: PaneProcessInfo, + }, + LayoutExport { + layout: LayoutDescription, + }, + LayoutApply { + layout: LayoutDescription, + }, + PaneNeighbor { + neighbor: PaneNeighborResult, + }, + PaneEdges { + edges: PaneEdgesResult, + }, + PaneFocusDirection { + focus: PaneFocusDirectionResult, + }, + PaneResize { + resize: PaneResizeResult, + }, + PaneRead { + read: PaneReadResult, + }, + AgentExplain { + explain: serde_json::Value, + }, + SubscriptionStarted {}, + WaitMatched { + event: EventEnvelope, + }, + OutputMatched { + pane_id: String, + revision: u64, + matched_line: Option<String>, + read: PaneReadResult, + }, + NotificationShow { + shown: bool, + reason: NotificationShowReason, + }, + ClientWindowTitle { + changed: bool, + reason: ClientWindowTitleReason, + }, + IntegrationInstall { + target: IntegrationTarget, + details: IntegrationInstallResult, + }, + IntegrationUninstall { + target: IntegrationTarget, + details: IntegrationUninstallResult, + }, + AgentManifestReload { + manifests: Vec<AgentManifestInfo>, + }, + AgentManifestStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + last_check_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_result: Option<String>, + manifests: Vec<AgentManifestInfo>, + }, + PluginLinked { + plugin: InstalledPluginInfo, + }, + PluginList { + plugins: Vec<InstalledPluginInfo>, + }, + PluginUnlinked { + plugin_id: String, + removed: bool, + }, + PluginEnabled { + plugin: InstalledPluginInfo, + }, + PluginDisabled { + plugin: InstalledPluginInfo, + }, + PluginActionList { + actions: Vec<PluginActionInfo>, + }, + PluginActionInvoked { + action: PluginActionInfo, + context: PluginInvocationContext, + log: PluginCommandLogInfo, + }, + PluginLogList { + logs: Vec<PluginCommandLogInfo>, + }, + PluginPaneOpened { + plugin_pane: PluginPaneInfo, + }, + PluginPaneFocused { + plugin_pane: PluginPaneInfo, + }, + PluginPaneClosed { + pane_id: String, + }, + ConfigReload { + status: crate::config::ConfigReloadStatus, + diagnostics: Vec<String>, + }, + Ok {}, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentManifestInfo { + pub agent: String, + pub source: String, + pub source_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_version: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_remote_version: Option<String>, + pub local_override_shadowing_remote: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_result: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_error: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_last_checked_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option<String>, +} diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs new file mode 100644 index 00000000..59ce8686 --- /dev/null +++ b/src/api/schema/server.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PingParams {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerLiveHandoffParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_exe: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_protocol: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_version: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerCapabilities { + pub live_handoff: bool, +} diff --git a/src/api/schema/tabs.rs b/src/api/schema/tabs.rs new file mode 100644 index 00000000..4be94c3b --- /dev/null +++ b/src/api/schema/tabs.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TabListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabRenameParams { + pub tab_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabInfo { + pub tab_id: String, + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub agent_status: AgentStatus, +} diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs new file mode 100644 index 00000000..c1713972 --- /dev/null +++ b/src/api/schema/tests.rs @@ -0,0 +1,975 @@ +use std::collections::HashMap; + +use super::*; + +#[test] +fn request_round_trips_for_pane_read() { + let request = Request { + id: "req_1".into(), + method: Method::PaneRead(PaneReadParams { + pane_id: "p_1".into(), + source: ReadSource::Recent, + lines: Some(80), + format: ReadFormat::Text, + strip_ansi: true, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_agent() { + let request = Request { + id: "req_hook".into(), + method: Method::PaneReportAgent(PaneReportAgentParams { + pane_id: "1-1".into(), + source: "herdr:pi".into(), + agent: "pi".into(), + state: PaneAgentState::Working, + message: Some("thinking".into()), + custom_status: Some("indexing".into()), + seq: Some(42), + agent_session_id: Some("pi-session".into()), + agent_session_path: Some("/tmp/pi-session.jsonl".into()), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_agent_session() { + let request = Request { + id: "req_session".into(), + method: Method::PaneReportAgentSession(PaneReportAgentSessionParams { + pane_id: "1-1".into(), + source: "herdr:claude".into(), + agent: "claude".into(), + seq: Some(42), + agent_session_id: Some("claude-session".into()), + agent_session_path: None, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_metadata() { + let request = Request { + id: "req_metadata".into(), + method: Method::PaneReportMetadata(PaneReportMetadataParams { + pane_id: "1-1".into(), + source: "user:claude-title".into(), + agent: Some("claude".into()), + applies_to_source: Some("herdr:claude".into()), + title: Some("Refactor auth".into()), + display_agent: Some("Claude auth".into()), + custom_status: Some("refactor auth".into()), + state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), + clear_title: false, + clear_display_agent: false, + clear_custom_status: false, + clear_state_labels: false, + seq: Some(42), + ttl_ms: Some(3_600_000), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_clear_agent_authority() { + let request = Request { + id: "req_clear".into(), + method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams { + pane_id: "1-1".into(), + source: Some("herdr:pi".into()), + seq: Some(42), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_release_agent() { + let request = Request { + id: "req_release".into(), + method: Method::PaneReleaseAgent(PaneReleaseAgentParams { + pane_id: "1-1".into(), + source: "herdr:pi".into(), + agent: "pi".into(), + seq: Some(42), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_uses_dot_method_names() { + let request = Request { + id: "req_1".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { + cwd: Some("/tmp".into()), + focus: true, + label: Some("api".into()), + env: Default::default(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "workspace.create"); +} + +#[test] +fn request_round_trips_for_server_stop() { + let request = Request { + id: "req_stop".into(), + method: Method::ServerStop(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.stop"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_reload_config() { + let request = Request { + id: "req_reload".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_config"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_reload_agent_manifests() { + let request = Request { + id: "req_reload_agent_manifests".into(), + method: Method::ServerReloadAgentManifests(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_agent_manifests"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_agent_manifests() { + let request = Request { + id: "req_agent_manifests".into(), + method: Method::ServerAgentManifests(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.agent_manifests"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_agent_explain() { + let request = Request { + id: "req_agent_explain".into(), + method: Method::AgentExplain(AgentTarget { + target: "agent-1".into(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "agent.explain"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn notification_show_request_parses() { + let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#; + let request: Request = serde_json::from_str(json).unwrap(); + let Method::NotificationShow(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.title, "build failed"); + assert_eq!(params.body.as_deref(), Some("api workspace")); + assert_eq!( + params.position, + Some(crate::config::ToastHerdrPosition::TopLeft) + ); + assert_eq!(params.sound, NotificationShowSound::Request); +} + +#[test] +fn notification_show_sound_defaults_to_none() { + let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed"}}"#; + let request: Request = serde_json::from_str(json).unwrap(); + let Method::NotificationShow(params) = request.method else { + panic!("wrong method parsed"); + }; + + assert_eq!(params.sound, NotificationShowSound::None); +} + +#[test] +fn client_window_title_requests_round_trip() { + let set = Request { + id: "req_title_set".into(), + method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams { + title: "herdr api".into(), + }), + }; + let json = serde_json::to_value(&set).unwrap(); + assert_eq!(json["method"], "client.window_title.set"); + assert_eq!(json["params"]["title"], "herdr api"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, set); + + let clear = Request { + id: "req_title_clear".into(), + method: Method::ClientWindowTitleClear(EmptyParams::default()), + }; + let json = serde_json::to_value(&clear).unwrap(); + assert_eq!(json["method"], "client.window_title.clear"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, clear); +} + +#[test] +fn unknown_method_is_rejected() { + let json = r#"{"id":"req_1","method":"nope","params":{}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant")); +} + +#[test] +fn missing_required_params_are_rejected() { + let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("text")); +} + +#[test] +fn pane_send_input_defaults_to_empty_text_and_keys() { + let json = r#" + { + "id": "req_1", + "method": "pane.send_input", + "params": { + "pane_id": "p_1" + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneSendInput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.pane_id, "p_1"); + assert!(params.text.is_empty()); + assert!(params.keys.is_empty()); +} + +#[test] +fn pane_wait_for_output_defaults_strip_ansi_to_true() { + let json = r#" + { + "id": "req_1", + "method": "pane.wait_for_output", + "params": { + "pane_id": "p_1", + "source": "recent", + "match": { "type": "substring", "value": "ready" } + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneWaitForOutput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert!(params.strip_ansi); +} + +#[test] +fn pane_read_defaults_to_text_format() { + let json = r#" + { + "id": "req_1", + "method": "pane.read", + "params": { + "pane_id": "p_1", + "source": "visible" + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneRead(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.format, ReadFormat::Text); +} + +#[test] +fn pane_current_request_round_trips() { + let request = Request { + id: "req_current".into(), + method: Method::PaneCurrent(PaneCurrentParams { + caller_pane_id: Some("w1-1".into()), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.current"); + assert_eq!(json["params"]["caller_pane_id"], "w1-1"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn pane_process_info_request_round_trips() { + let request = Request { + id: "req_process_info".into(), + method: Method::PaneProcessInfo(PaneProcessInfoParams { + pane_id: Some("w1-1".into()), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.process_info"); + assert_eq!(json["params"]["pane_id"], "w1-1"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn event_envelope_round_trips() { + let event = EventEnvelope { + event: EventKind::PaneOutputChanged, + data: EventData::PaneOutputChanged { + pane_id: "p_1".into(), + workspace_id: "w_1".into(), + revision: 42, + }, + }; + + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); +} + +#[test] +fn subscribe_request_parses_parameterized_subscriptions() { + let json = r#" + { + "id": "sub_1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { + "type": "pane.output_matched", + "pane_id": "p_1_1", + "source": "recent", + "lines": 200, + "match": { "type": "substring", "value": "auth: received" } + }, + { + "type": "pane.agent_status_changed", + "pane_id": "p_1_1", + "agent_status": "done" + } + ] + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsSubscribe(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.subscriptions.len(), 2); + assert!(matches!( + ¶ms.subscriptions[0], + Subscription::PaneOutputMatched { + pane_id, + source: ReadSource::Recent, + lines: Some(200), + r#match: OutputMatch::Substring { value }, + strip_ansi: true, + } if pane_id == "p_1_1" && value == "auth: received" + )); + assert!(matches!( + ¶ms.subscriptions[1], + Subscription::PaneAgentStatusChanged { + pane_id, + agent_status: Some(AgentStatus::Done), + } if pane_id == "p_1_1" + )); +} + +#[test] +fn subscription_event_envelope_round_trips() { + let event = SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneOutputMatched, + data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { + pane_id: "p_1_1".into(), + matched_line: "auth: received".into(), + read: PaneReadResult { + pane_id: "p_1_1".into(), + workspace_id: "w_1".into(), + tab_id: "t_1_1".into(), + source: ReadSource::Recent, + format: ReadFormat::Text, + text: "auth: received\n".into(), + revision: 0, + truncated: false, + }, + }), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"event\":\"pane.output_matched\"")); + let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); +} + +#[test] +fn success_response_round_trips() { + let response = SuccessResponse { + id: "req_1".into(), + result: ResponseResult::Pong { + version: "0.1.2".into(), + protocol: 6, + capabilities: Some(ServerCapabilities { live_handoff: true }), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn worktree_request_and_response_round_trip() { + let request = Request { + id: "req_worktree".into(), + method: Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some("1".into()), + branch: Some("worktree/api".into()), + base: Some("HEAD".into()), + focus: true, + ..WorktreeCreateParams::default() + }), + }; + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); + + let response = SuccessResponse { + id: "req_worktree".into(), + result: ResponseResult::WorktreeCreated { + workspace: WorkspaceInfo { + workspace_id: "w_1".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_1:1".into(), + agent_status: AgentStatus::Unknown, + worktree: Some(WorkspaceWorktreeInfo { + repo_key: "/repo/herdr/.git".into(), + repo_name: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/worktrees/herdr/worktree-api".into(), + is_linked_worktree: true, + }), + }, + tab: TabInfo { + tab_id: "w_1:1".into(), + workspace_id: "w_1".into(), + number: 1, + label: "herdr".into(), + focused: true, + pane_count: 1, + agent_status: AgentStatus::Unknown, + }, + root_pane: PaneInfo { + pane_id: "w_1-1".into(), + terminal_id: "term_1".into(), + workspace_id: "w_1".into(), + tab_id: "w_1:1".into(), + focused: true, + cwd: Some("/worktrees/herdr/worktree-api".into()), + foreground_cwd: None, + label: None, + agent: None, + title: None, + display_agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + state_labels: HashMap::new(), + agent_session: None, + revision: 0, + }, + worktree: WorktreeInfo { + path: "/worktrees/herdr/worktree-api".into(), + branch: Some("worktree/api".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some("w_1".into()), + label: "herdr".into(), + }, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"type\":\"worktree_created\"")); + assert!(json.contains("\"worktree\"")); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn worktree_lifecycle_events_round_trip() { + let subscription = Request { + id: "sub_worktrees".into(), + method: Method::EventsSubscribe(EventsSubscribeParams { + subscriptions: vec![ + Subscription::WorktreeCreated {}, + Subscription::WorktreeOpened {}, + Subscription::WorktreeRemoved {}, + ], + }), + }; + let json = serde_json::to_string(&subscription).unwrap(); + assert!(json.contains("\"type\":\"worktree.created\"")); + assert!(json.contains("\"type\":\"worktree.opened\"")); + assert!(json.contains("\"type\":\"worktree.removed\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, subscription); + + let workspace = WorkspaceInfo { + workspace_id: "w_2".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_2:1".into(), + agent_status: AgentStatus::Unknown, + worktree: Some(WorkspaceWorktreeInfo { + repo_key: "/repo/herdr/.git".into(), + repo_name: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/worktrees/herdr/worktree-api".into(), + is_linked_worktree: true, + }), + }; + let worktree = WorktreeInfo { + path: "/worktrees/herdr/worktree-api".into(), + branch: Some("worktree/api".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some("w_2".into()), + label: "herdr".into(), + }; + + for event in [ + EventEnvelope { + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: workspace.clone(), + worktree: worktree.clone(), + }, + }, + EventEnvelope { + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: workspace.clone(), + worktree: worktree.clone(), + already_open: false, + }, + }, + EventEnvelope { + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id: "w_2".into(), + worktree: WorktreeInfo { + open_workspace_id: None, + ..worktree.clone() + }, + forced: false, + }, + }, + EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: "w_2".into(), + workspace: Some(workspace.clone()), + }, + }, + ] { + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); + } +} + +#[test] +fn plugin_link_list_unlink_round_trip() { + let link = Request { + id: "plugin_link".into(), + method: Method::PluginLink(PluginLinkParams { + path: "/plugins/worktree-bootstrap".into(), + enabled: true, + source: None, + }), + }; + let json = serde_json::to_string(&link).unwrap(); + assert!(json.contains("\"method\":\"plugin.link\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, link); + + let list = Request { + id: "plugin_list".into(), + method: Method::PluginList(PluginListParams { + plugin_id: Some("example.worktree-bootstrap".into()), + }), + }; + let json = serde_json::to_string(&list).unwrap(); + assert!(json.contains("\"method\":\"plugin.list\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, list); + + let unlink = Request { + id: "plugin_unlink".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }; + let json = serde_json::to_string(&unlink).unwrap(); + assert!(json.contains("\"method\":\"plugin.unlink\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, unlink); + + let plugin = InstalledPluginInfo { + plugin_id: "example.worktree-bootstrap".into(), + name: "Worktree Bootstrap".into(), + version: "0.1.0".into(), + description: Some("Prepare new worktrees".into()), + manifest_path: "/plugins/worktree-bootstrap/herdr-plugin.toml".into(), + plugin_root: "/plugins/worktree-bootstrap".into(), + enabled: true, + platforms: None, + build: vec![PluginManifestBuild { + platforms: None, + command: vec!["bun".into(), "install".into()], + }], + actions: vec![PluginManifestAction { + id: "bootstrap".into(), + title: "Bootstrap worktree".into(), + description: None, + contexts: vec![PluginActionContext::Workspace], + platforms: None, + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + events: vec![PluginManifestEventHook { + on: "worktree.created".into(), + platforms: None, + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + panes: vec![PluginManifestPane { + id: "board".into(), + title: "Board".into(), + description: None, + platforms: None, + placement: PluginPanePlacement::Overlay, + command: vec!["bun".into(), "run".into(), "board.ts".into()], + }], + link_handlers: vec![PluginManifestLinkHandler { + id: "github-pr".into(), + title: "Open GitHub PR".into(), + pattern: "^https://github.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(), + action: "bootstrap".into(), + platforms: None, + }], + source: Default::default(), + warnings: vec![], + }; + + for response in [ + SuccessResponse { + id: "plugin_link".into(), + result: ResponseResult::PluginLinked { + plugin: plugin.clone(), + }, + }, + SuccessResponse { + id: "plugin_list".into(), + result: ResponseResult::PluginList { + plugins: vec![plugin.clone()], + }, + }, + SuccessResponse { + id: "plugin_unlink".into(), + result: ResponseResult::PluginUnlinked { + plugin_id: plugin.plugin_id.clone(), + removed: true, + }, + }, + ] { + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } +} + +#[test] +fn layout_export_apply_round_trip() { + let root = LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.6, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + cwd: Some("/repo".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("tests".into()), + command: Some(vec!["sh".into(), "-c".into(), "just test".into()]), + env: HashMap::from([("HERDR_ROLE".into(), "tests".into())]), + ..Default::default() + }, + }), + }; + + let export = Request { + id: "layout_export".into(), + method: Method::LayoutExport(LayoutExportParams { + tab_id: Some("w1:1".into()), + pane_id: None, + }), + }; + let json = serde_json::to_string(&export).unwrap(); + assert!(json.contains("\"method\":\"layout.export\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, export); + + let apply = Request { + id: "layout_apply".into(), + method: Method::LayoutApply(LayoutApplyParams { + workspace_id: Some("w1".into()), + tab_id: None, + tab_label: Some("dev".into()), + focus: true, + root: root.clone(), + }), + }; + let json = serde_json::to_string(&apply).unwrap(); + assert!(json.contains("\"method\":\"layout.apply\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, apply); + + let response = SuccessResponse { + id: "layout_export".into(), + result: ResponseResult::LayoutExport { + layout: LayoutDescription { + workspace_id: "w1".into(), + tab_id: "w1:1".into(), + zoomed: false, + focused_pane_id: "w1-1".into(), + root, + }, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn create_response_round_trips_with_root_pane() { + let response = SuccessResponse { + id: "req_2".into(), + result: ResponseResult::TabCreated { + tab: TabInfo { + tab_id: "w_1:2".into(), + workspace_id: "w_1".into(), + number: 2, + label: "review".into(), + focused: false, + pane_count: 1, + agent_status: AgentStatus::Unknown, + }, + root_pane: PaneInfo { + pane_id: "w_1-3".into(), + terminal_id: "term_example".into(), + workspace_id: "w_1".into(), + tab_id: "w_1:2".into(), + focused: false, + cwd: Some("/tmp/review".into()), + foreground_cwd: None, + label: None, + agent: None, + title: None, + display_agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + state_labels: HashMap::new(), + agent_session: None, + revision: 0, + }, + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"type\":\"tab_created\"")); + assert!(json.contains("\"root_pane\"")); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn error_response_round_trips() { + let response = ErrorResponse { + id: "req_1".into(), + error: ErrorBody { + code: "pane_not_found".into(), + message: "pane p_1 not found".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: ErrorResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn event_wait_parses_typed_match() { + let json = r#" + { + "id": "req_9", + "method": "events.wait", + "params": { + "match_event": { + "event": "pane_agent_status_changed", + "pane_id": "p_1", + "agent_status": "done" + }, + "timeout_ms": 30000 + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsWait(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!( + params.match_event, + EventMatch::PaneAgentStatusChanged { + pane_id: "p_1".into(), + agent_status: AgentStatus::Done, + } + ); +} + +#[test] +fn plugin_action_list_and_invoke_round_trips() { + let list = Request { + id: "req_plugin_action_list".into(), + method: Method::PluginActionList(PluginActionListParams { + plugin_id: Some("example.issue-flow".into()), + }), + }; + let json = serde_json::to_value(&list).unwrap(); + assert_eq!(json["method"], "plugin.action.list"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, list); + + let invoke = Request { + id: "req_plugin_action_invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.issue-flow".into()), + action_id: "assign-issue".into(), + context: None, + }), + }; + let json = serde_json::to_value(&invoke).unwrap(); + assert_eq!(json["method"], "plugin.action.invoke"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, invoke); + + let action_info = PluginActionInfo { + plugin_id: "example.issue-flow".into(), + action_id: "assign-issue".into(), + title: "Assign Issue".into(), + description: Some("Open the issue assignment UI".into()), + contexts: vec![PluginActionContext::Workspace, PluginActionContext::Pane], + command: vec!["assign".into(), "--issue".into()], + platforms: Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]), + }; + assert_eq!( + action_info.qualified_id(), + "example.issue-flow.assign-issue" + ); + let json = serde_json::to_string(&action_info).unwrap(); + let restored: PluginActionInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, action_info); +} + +#[test] +fn plugin_pane_open_request_round_trips() { + let request = Request { + id: "req_plugin_pane".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.board".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Zoomed), + workspace_id: None, + target_pane_id: Some("1-1".into()), + direction: Some(SplitDirection::Right), + cwd: Some("/tmp".into()), + focus: true, + env: [("HERDR_ROLE".to_string(), "board".to_string())].into(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "plugin.pane.open"); + assert_eq!(json["params"]["env"]["HERDR_ROLE"], "board"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs new file mode 100644 index 00000000..d15cf09b --- /dev/null +++ b/src/api/schema/workspaces.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRenameParams { + pub workspace_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub tab_count: usize, + pub active_tab_id: String, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option<WorkspaceWorktreeInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceWorktreeInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub checkout_path: String, + pub is_linked_worktree: bool, +} diff --git a/src/api/schema/worktrees.rs b/src/api/schema/worktrees.rs new file mode 100644 index 00000000..f480edb1 --- /dev/null +++ b/src/api/schema/worktrees.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeOpenParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeRemoveParams { + pub workspace_id: String, + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeSourceInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub source_checkout_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + pub is_bare: bool, + pub is_detached: bool, + pub is_prunable: bool, + pub is_linked_worktree: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_workspace_id: Option<String>, + pub label: String, +} diff --git a/src/api/server.rs b/src/api/server.rs index 5650b2d0..e9e2b01b 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -279,6 +279,8 @@ fn api_method_name(method: &Method) -> &'static str { Method::ServerAgentManifests(_) => "server.agent_manifests", Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests", Method::NotificationShow(_) => "notification.show", + Method::ClientWindowTitleSet(_) => "client.window_title.set", + Method::ClientWindowTitleClear(_) => "client.window_title.clear", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", Method::WorkspaceGet(_) => "workspace.get", @@ -308,11 +310,15 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneMove(_) => "pane.move", Method::PaneZoom(_) => "pane.zoom", Method::PaneLayout(_) => "pane.layout", + Method::PaneProcessInfo(_) => "pane.process_info", + Method::LayoutExport(_) => "layout.export", + Method::LayoutApply(_) => "layout.apply", Method::PaneNeighbor(_) => "pane.neighbor", Method::PaneEdges(_) => "pane.edges", Method::PaneFocusDirection(_) => "pane.focus_direction", Method::PaneResize(_) => "pane.resize", Method::PaneList(_) => "pane.list", + Method::PaneCurrent(_) => "pane.current", Method::PaneGet(_) => "pane.get", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", @@ -330,6 +336,17 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneWaitForOutput(_) => "pane.wait_for_output", Method::IntegrationInstall(_) => "integration.install", Method::IntegrationUninstall(_) => "integration.uninstall", + Method::PluginLink(_) => "plugin.link", + Method::PluginList(_) => "plugin.list", + Method::PluginUnlink(_) => "plugin.unlink", + Method::PluginEnable(_) => "plugin.enable", + Method::PluginDisable(_) => "plugin.disable", + Method::PluginActionList(_) => "plugin.action.list", + Method::PluginActionInvoke(_) => "plugin.action.invoke", + Method::PluginLogList(_) => "plugin.log.list", + Method::PluginPaneOpen(_) => "plugin.pane.open", + Method::PluginPaneFocus(_) => "plugin.pane.focus", + Method::PluginPaneClose(_) => "plugin.pane.close", } } diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index e8e362fe..d71ea24d 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -128,6 +128,18 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceFocused, last_sequence: 0, })), + Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeCreated, + last_sequence: 0, + })), + Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeOpened, + last_sequence: 0, + })), + Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeRemoved, + last_sequence: 0, + })), Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::TabCreated, last_sequence: 0, diff --git a/src/app/actions.rs b/src/app/actions.rs index 23b886d9..a7d99f35 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1349,6 +1349,15 @@ impl AppState { .collect() } + pub(crate) fn pane_ids_for_workspace(&self, ws_idx: usize) -> Vec<PaneId> { + self.workspaces + .get(ws_idx) + .into_iter() + .flat_map(|ws| &ws.tabs) + .flat_map(|tab| tab.layout.pane_ids()) + .collect() + } + pub(crate) fn terminal_ids_for_tab( &self, ws_idx: usize, @@ -1363,6 +1372,14 @@ impl AppState { .collect() } + pub(crate) fn pane_ids_for_tab(&self, ws_idx: usize, tab_idx: usize) -> Vec<PaneId> { + self.workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .map(|tab| tab.layout.pane_ids()) + .unwrap_or_default() + } + pub(crate) fn terminal_id_for_pane( &self, ws_idx: usize, @@ -1395,6 +1412,15 @@ impl AppState { } } + pub(crate) fn remove_plugin_pane_records( + &mut self, + pane_ids: impl IntoIterator<Item = PaneId>, + ) { + for pane_id in pane_ids { + self.plugin_panes.remove(&pane_id); + } + } + pub fn close_selected_workspace(&mut self) { if self.workspaces.is_empty() { return; @@ -1422,12 +1448,15 @@ impl AppState { .unwrap_or_else(|| vec![self.selected]); let mut terminal_ids = Vec::new(); + let mut pane_ids = Vec::new(); for idx in &close_indices { terminal_ids.extend(self.terminal_ids_for_workspace(*idx)); + pane_ids.extend(self.pane_ids_for_workspace(*idx)); if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) { crate::logging::workspace_closed(&workspace_id); } } + self.remove_plugin_pane_records(pane_ids); for idx in close_indices.iter().rev() { self.workspaces.remove(*idx); } @@ -1761,9 +1790,14 @@ impl AppState { .and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id)) .into_iter() .collect::<Vec<_>>(); + let pane_ids = active + .and_then(|i| self.workspaces.get(i).and_then(|ws| ws.focused_pane_id())) + .into_iter() + .collect::<Vec<_>>(); let should_close_workspace = active .and_then(|i| self.workspaces.get_mut(i)) .is_some_and(|ws| ws.close_focused()); + self.remove_plugin_pane_records(pane_ids); if should_close_workspace { if let Some(active) = active { self.selected = active; @@ -1810,12 +1844,18 @@ impl AppState { .get(ws_idx) .map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab)) .unwrap_or_default(); + let pane_ids = self + .workspaces + .get(ws_idx) + .map(|ws| self.pane_ids_for_tab(ws_idx, ws.active_tab)) + .unwrap_or_default(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { return false; }; let workspace_id = ws.id.clone(); let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1); ws.close_active_tab(); + self.remove_plugin_pane_records(pane_ids); self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); self.tab_scroll_follow_active = true; @@ -2502,6 +2542,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::PluginCommandFinished { .. } => Vec::new(), } } @@ -2791,6 +2832,7 @@ impl AppState { fn handle_pane_died(&mut self, pane_id: PaneId) { self.pending_agent_notifications.remove(&pane_id); + self.plugin_panes.remove(&pane_id); let ws_idx = self .workspaces .iter() @@ -3161,10 +3203,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); @@ -4875,11 +4917,19 @@ mod tests { #[test] fn close_pane_removes_from_workspace() { let mut state = app_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); + let closed = state.workspaces[0].test_split(Direction::Horizontal); assert_eq!(state.workspaces[0].panes.len(), 2); + state.plugin_panes.insert( + closed, + crate::app::state::PluginPaneRecord { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + }, + ); state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); + assert!(!state.plugin_panes.contains_key(&closed)); } #[test] @@ -4935,22 +4985,37 @@ mod tests { state.workspaces[0].switch_tab(tab_idx); let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + state.plugin_panes.insert( + pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + }, + ); state.close_tab(); assert!(!state.terminals.contains_key(&terminal_id)); + assert!(!state.plugin_panes.contains_key(&pane_id)); } #[test] fn close_workspace_removes_unattached_terminal_states() { let mut state = app_with_workspaces(&["one", "two"]); - let terminal_id = state - .terminal_id_for_pane(0, state.workspaces[0].tabs[0].root_pane) - .unwrap(); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + state.plugin_panes.insert( + pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + }, + ); state.close_selected_workspace(); assert!(!state.terminals.contains_key(&terminal_id)); + assert!(!state.plugin_panes.contains_key(&pane_id)); } #[test] diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index cf691e33..6a24487f 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -223,9 +223,12 @@ impl App { ); return false; }; - let public_pane_id = self + let Some(launch_env) = self .find_pane(pane_id) - .and_then(|(ws_idx, _)| self.public_pane_id(ws_idx, pane_id)); + .and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, Vec::new())) + else { + return false; + }; let runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, @@ -235,10 +238,10 @@ impl App { self.state.pane_scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + &launch_env, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), - public_pane_id.as_deref(), ) { Ok(runtime) => runtime, Err(err) => { diff --git a/src/app/agents.rs b/src/app/agents.rs index ea828eae..262bb0e4 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -97,6 +97,7 @@ impl App { pub(super) fn start_agent( &mut self, params: AgentStartParams, + extra_env: Vec<(String, String)>, ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> { let name = params.name.trim().to_string(); if name.is_empty() { @@ -145,6 +146,7 @@ impl App { params.split.unwrap_or(SplitDirection::Right), cwd, &argv, + extra_env, focus, )? } else if let Some(workspace_id) = params.workspace_id { @@ -161,10 +163,11 @@ impl App { params.split.unwrap_or(SplitDirection::Right), cwd, &argv, + extra_env, focus, )? } else if self.state.workspaces.is_empty() { - self.spawn_agent_workspace(cwd, rows, cols, &argv, focus)? + self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)? } else { let ws_idx = self.state.active.unwrap_or(0); let tab_idx = self.state.workspaces[ws_idx].active_tab; @@ -175,6 +178,7 @@ impl App { params.split.unwrap_or(SplitDirection::Right), cwd, &argv, + extra_env, focus, )? }; @@ -314,9 +318,10 @@ impl App { rows: u16, cols: u16, argv: &[String], + extra_env: Vec<(String, String)>, focus: bool, ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command( + let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env( cwd, rows, cols, @@ -326,6 +331,7 @@ impl App { self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), + extra_env, ) .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; self.terminal_runtimes.insert(terminal.id.clone(), runtime); @@ -350,6 +356,7 @@ impl App { split: SplitDirection, cwd: PathBuf, argv: &[String], + extra_env: Vec<(String, String)>, focus: bool, ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { let (rows, cols) = self.state.estimate_pane_size(); @@ -370,6 +377,7 @@ impl App { cols, Some(cwd), argv, + extra_env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, focus, diff --git a/src/app/api.rs b/src/app/api.rs index 3a7276c4..6dc48953 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -2,8 +2,11 @@ use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; mod agents; +mod env; mod integrations; +mod layouts; mod panes; +pub(crate) mod plugins; mod responses; mod tabs; mod workspaces; @@ -56,6 +59,37 @@ impl App { return; } + if let AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms, + exit_code, + stdout, + stderr, + error, + } = ev + { + self.state.plugin_commands_in_flight = + self.state.plugin_commands_in_flight.saturating_sub(1); + if let Some(log) = self + .state + .plugin_command_logs + .iter_mut() + .find(|log| log.log_id == log_id) + { + log.finished_unix_ms = Some(finished_unix_ms); + log.exit_code = exit_code; + log.stdout = Some(stdout); + log.stderr = Some(stderr); + log.error = error; + log.status = if log.error.is_none() && log.exit_code == Some(0) { + crate::api::schema::PluginCommandStatus::Succeeded + } else { + crate::api::schema::PluginCommandStatus::Failed + }; + } + return; + } + if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_worktree_add_finished(result); return; @@ -327,7 +361,6 @@ impl App { let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { return false; }; - let public_pane_id = self.public_pane_id(ws_idx, pane_id); let terminal_id = pane_state.attached_terminal_id.clone(); let Some(terminal) = self.state.terminals.get(&terminal_id) else { return false; @@ -339,6 +372,9 @@ impl App { .get(&terminal_id) .map(|runtime| runtime.current_size()) .unwrap_or_else(|| self.state.estimate_pane_size()); + let Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else { + return false; + }; let runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, rows, @@ -347,10 +383,10 @@ impl App { self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + &launch_env, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), - public_pane_id.as_deref(), ) { Ok(runtime) => runtime, Err(err) => { @@ -373,7 +409,7 @@ impl App { true } - pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { + pub(crate) fn emit_pane_state_update(&mut self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; }; @@ -573,7 +609,8 @@ impl App { } } - pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { + pub(super) fn emit_event(&mut self, event: crate::api::schema::EventEnvelope) { + self.run_plugin_event_hooks(&event); self.event_hub.push(event); } @@ -716,6 +753,15 @@ impl App { Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } + Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => { + return responses::encode_success( + request.id, + ResponseResult::ClientWindowTitle { + changed: false, + reason: crate::api::schema::ClientWindowTitleReason::NoForegroundClient, + }, + ); + } Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { @@ -757,6 +803,11 @@ impl App { Method::PaneMove(params) => return self.handle_pane_move(request.id, params), Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params), Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params), + Method::PaneProcessInfo(params) => { + return self.handle_pane_process_info(request.id, params); + } + Method::LayoutExport(params) => return self.handle_layout_export(request.id, params), + Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params), Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params), Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params), Method::PaneFocusDirection(params) => { @@ -764,6 +815,7 @@ impl App { } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), Method::PaneList(params) => return self.handle_pane_list(request.id, params), + Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), @@ -794,6 +846,39 @@ impl App { Method::IntegrationUninstall(params) => { return self.handle_integration_uninstall(request.id, params); } + Method::PluginLink(params) => { + return self.handle_plugin_link(request.id, params); + } + Method::PluginList(params) => { + return self.handle_plugin_list(request.id, params); + } + Method::PluginUnlink(params) => { + return self.handle_plugin_unlink(request.id, params); + } + Method::PluginEnable(params) => { + return self.handle_plugin_enable(request.id, params); + } + Method::PluginDisable(params) => { + return self.handle_plugin_disable(request.id, params); + } + Method::PluginActionList(params) => { + return self.handle_plugin_action_list(request.id, params); + } + Method::PluginActionInvoke(params) => { + return self.handle_plugin_action_invoke(request.id, params); + } + Method::PluginLogList(params) => { + return self.handle_plugin_log_list(request.id, params); + } + Method::PluginPaneOpen(params) => { + return self.handle_plugin_pane_open(request.id, params); + } + Method::PluginPaneFocus(params) => { + return self.handle_plugin_pane_focus(request.id, params); + } + Method::PluginPaneClose(params) => { + return self.handle_plugin_pane_close(request.id, params); + } _ => { return responses::encode_error( request.id, @@ -1186,6 +1271,75 @@ mod tests { ); } + #[tokio::test] + async fn pane_process_info_returns_response_for_existing_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("process-info")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id, runtime); + let target = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "process_info".into(), + method: crate::api::schema::Method::PaneProcessInfo( + crate::api::schema::PaneProcessInfoParams { + pane_id: Some(target.clone()), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_process_info"); + assert_eq!(response["result"]["process_info"]["pane_id"], target); + } + + #[test] + fn client_window_title_api_reports_no_foreground_client_in_app_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + + let set = app.handle_api_request(crate::api::schema::Request { + id: "title_set".into(), + method: crate::api::schema::Method::ClientWindowTitleSet( + crate::api::schema::ClientWindowTitleSetParams { + title: "plugin review".into(), + }, + ), + }); + let set: serde_json::Value = serde_json::from_str(&set).unwrap(); + assert_eq!(set["result"]["type"], "client_window_title"); + assert_eq!(set["result"]["changed"], false); + assert_eq!(set["result"]["reason"], "no_foreground_client"); + + let clear = app.handle_api_request(crate::api::schema::Request { + id: "title_clear".into(), + method: crate::api::schema::Method::ClientWindowTitleClear( + crate::api::schema::EmptyParams::default(), + ), + }); + let clear: serde_json::Value = serde_json::from_str(&clear).unwrap(); + assert_eq!(clear["result"]["type"], "client_window_title"); + assert_eq!(clear["result"]["reason"], "no_foreground_client"); + } + #[cfg(unix)] #[tokio::test] async fn herdr_toast_context_uses_live_root_runtime_cwd_label() { @@ -1236,10 +1390,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -1328,10 +1482,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 471b3052..7293823f 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -46,7 +46,11 @@ impl App { } pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { - let (agent, argv) = match self.start_agent(params) { + let extra_env = match super::env::normalize_launch_env(params.env.clone()) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let (agent, argv) = match self.start_agent(params, extra_env) { Ok(started) => started, Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), }; diff --git a/src/app/api/env.rs b/src/app/api/env.rs new file mode 100644 index 00000000..db421f63 --- /dev/null +++ b/src/app/api/env.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; + +pub(super) fn normalize_launch_env( + env: HashMap<String, String>, +) -> Result<Vec<(String, String)>, (String, String)> { + let mut normalized = Vec::with_capacity(env.len()); + for (key, value) in env { + if key.is_empty() { + return Err(("invalid_env".into(), "env key must not be empty".into())); + } + if key.contains('=') { + return Err(( + "invalid_env".into(), + format!("env key {key} must not contain '='"), + )); + } + if key.contains('\0') { + return Err(( + "invalid_env".into(), + "env key must not contain NUL bytes".into(), + )); + } + if value.contains('\0') { + return Err(( + "invalid_env".into(), + format!("env value for {key} must not contain NUL bytes"), + )); + } + normalized.push((key, value)); + } + normalized.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_launch_env_sorts_entries() { + let env = HashMap::from([ + ("ZED".to_string(), "last".to_string()), + ("ALPHA".to_string(), "first".to_string()), + ]); + + assert_eq!( + normalize_launch_env(env).unwrap(), + vec![ + ("ALPHA".to_string(), "first".to_string()), + ("ZED".to_string(), "last".to_string()), + ] + ); + } + + #[test] + fn normalize_launch_env_rejects_invalid_keys() { + let env = HashMap::from([("BAD=KEY".to_string(), "value".to_string())]); + + assert_eq!(normalize_launch_env(env).unwrap_err().0, "invalid_env"); + } +} diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs new file mode 100644 index 00000000..e397ccf8 --- /dev/null +++ b/src/app/api/layouts.rs @@ -0,0 +1,758 @@ +use std::path::PathBuf; + +use ratatui::layout::Direction; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, LayoutApplyParams, LayoutDescription, LayoutExportParams, + LayoutNode, LayoutPane, ResponseResult, SplitDirection, +}; +use crate::app::{App, Mode}; +use crate::layout::{Node, PaneId}; +use crate::workspace::NewPane; + +use super::responses::{encode_error, encode_success}; + +const MAX_LAYOUT_PANES: usize = 24; +const MAX_LAYOUT_DEPTH: usize = 16; + +impl App { + pub(super) fn handle_layout_export( + &mut self, + id: String, + params: LayoutExportParams, + ) -> String { + let Some((ws_idx, tab_idx)) = self.resolve_layout_export_target(¶ms) else { + return encode_error(id, "layout_not_found", "layout target not found"); + }; + let Some(layout) = self.layout_description(ws_idx, tab_idx) else { + return encode_error(id, "layout_not_found", "layout unavailable"); + }; + + encode_success(id, ResponseResult::LayoutExport { layout }) + } + + pub(super) fn handle_layout_apply(&mut self, id: String, params: LayoutApplyParams) -> String { + let replace_target = match params.tab_id.as_deref() { + Some(tab_id) => match self.parse_tab_id(tab_id) { + Some(target) => Some(target), + None => { + return encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) + } + }, + None => None, + }; + if replace_target.is_some() && params.workspace_id.is_some() { + return encode_error( + id, + "invalid_target", + "use either tab_id or workspace_id, not both", + ); + } + + let ws_idx = if let Some((ws_idx, _)) = replace_target { + ws_idx + } else if let Some(workspace_id) = params.workspace_id.as_deref() { + let Some(ws_idx) = self.parse_workspace_id(workspace_id) else { + return encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ); + }; + ws_idx + } else if let Some(active) = self.state.active { + active + } else { + return encode_error(id, "workspace_not_found", "no active workspace"); + }; + if let Err(message) = validate_layout_tree(¶ms.root) { + return encode_error(id, "invalid_layout", message); + } + + let replacement_label = params.tab_label.clone().or_else(|| { + let (_, tab_idx) = replace_target?; + self.state + .workspaces + .get(ws_idx)? + .tabs + .get(tab_idx)? + .custom_name + .clone() + }); + let replace_was_active = replace_target.is_some_and(|(target_ws, target_tab)| { + self.state.active == Some(target_ws) + && self + .state + .workspaces + .get(target_ws) + .is_some_and(|ws| ws.active_tab_index() == target_tab) + }); + let root_leaf = first_layout_leaf(¶ms.root); + let first_cwd = self.layout_root_cwd(ws_idx, replace_target, root_leaf); + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let command = match layout_command(root_leaf) { + Ok(command) => command, + Err(message) => return encode_error(id, "invalid_layout", message), + }; + + let created = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + if let Some(argv) = command.as_deref() { + ws.create_tab_argv_command( + rows, + cols, + first_cwd, + argv, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + ) + } else { + ws.create_tab( + rows, + cols, + first_cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), + extra_env, + ) + } + }; + + let (new_tab_idx, terminal, runtime) = match created { + Ok(result) => result, + Err(err) => return encode_error(id, "layout_apply_failed", err.to_string()), + }; + let new_root_pane = self.state.workspaces[ws_idx].tabs[new_tab_idx].root_pane; + self.terminal_runtimes.insert(terminal.id.clone(), runtime); + self.state.remove_alias_shadowed_by_new_pane(new_root_pane); + self.state.terminals.insert(terminal.id.clone(), terminal); + if let Some(label) = replacement_label { + self.state.workspaces[ws_idx].tabs[new_tab_idx].set_custom_name(label); + } + self.apply_layout_pane_label(ws_idx, new_root_pane, root_leaf); + + if let Err(message) = self.apply_layout_node_to_pane(ws_idx, new_root_pane, ¶ms.root) { + self.rollback_layout_tab(ws_idx, new_root_pane); + return encode_error(id, "layout_apply_failed", message); + } + + if let Some((target_ws_idx, target_tab_idx)) = replace_target { + let closed_tab_id = self + .public_tab_id(target_ws_idx, target_tab_idx) + .unwrap_or_else(|| { + format!( + "{}:{}", + self.public_workspace_id(target_ws_idx), + target_tab_idx + 1 + ) + }); + let terminal_ids = self + .state + .terminal_ids_for_tab(target_ws_idx, target_tab_idx); + let Some(ws) = self.state.workspaces.get_mut(target_ws_idx) else { + return encode_error(id, "tab_not_found", "tab not found"); + }; + if ws.close_tab(target_tab_idx) { + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::TabClosed, + data: EventData::TabClosed { + tab_id: closed_tab_id, + workspace_id: self.public_workspace_id(target_ws_idx), + }, + }); + } + } + + let Some(new_tab_idx) = self.state.workspaces[ws_idx] + .tabs + .iter() + .position(|tab| tab.root_pane == new_root_pane) + else { + return encode_error(id, "layout_apply_failed", "new layout tab disappeared"); + }; + + if params.focus || replace_was_active { + self.state.switch_workspace_tab(ws_idx, new_tab_idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + if let Some(tab) = self.tab_info(ws_idx, new_tab_idx) { + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab }, + }); + } + for pane_id in self.state.workspaces[ws_idx].tabs[new_tab_idx] + .layout + .pane_ids() + { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane }, + }); + } + } + + let Some(layout) = self.layout_description(ws_idx, new_tab_idx) else { + return encode_error(id, "layout_apply_failed", "new layout unavailable"); + }; + encode_success(id, ResponseResult::LayoutApply { layout }) + } + + fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> { + match (params.tab_id.as_deref(), params.pane_id.as_deref()) { + (Some(_), Some(_)) => None, + (Some(tab_id), None) => self.parse_tab_id(tab_id), + (None, Some(pane_id)) => { + let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?; + let tab_idx = self + .state + .workspaces + .get(ws_idx)? + .find_tab_index_for_pane(pane_id)?; + Some((ws_idx, tab_idx)) + } + (None, None) => { + let ws_idx = self.state.active?; + let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index(); + Some((ws_idx, tab_idx)) + } + } + } + + fn layout_description(&self, ws_idx: usize, tab_idx: usize) -> Option<LayoutDescription> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + Some(LayoutDescription { + workspace_id: self.public_workspace_id(ws_idx), + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + zoomed: tab.zoomed, + focused_pane_id: self.public_pane_id(ws_idx, tab.layout.focused())?, + root: self.layout_node_description(ws_idx, tab_idx, tab.layout.root())?, + }) + } + + fn layout_node_description( + &self, + ws_idx: usize, + tab_idx: usize, + node: &Node, + ) -> Option<LayoutNode> { + match node { + Node::Pane(pane_id) => Some(LayoutNode::Pane { + pane: self.layout_pane_description(ws_idx, tab_idx, *pane_id)?, + }), + Node::Split { + direction, + ratio, + first, + second, + } => Some(LayoutNode::Split { + direction: match direction { + Direction::Horizontal => SplitDirection::Right, + Direction::Vertical => SplitDirection::Down, + }, + ratio: *ratio, + first: Box::new(self.layout_node_description(ws_idx, tab_idx, first)?), + second: Box::new(self.layout_node_description(ws_idx, tab_idx, second)?), + }), + } + } + + fn layout_pane_description( + &self, + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + ) -> Option<LayoutPane> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + let terminal_id = tab.terminal_id(pane_id)?; + let terminal = self.state.terminals.get(terminal_id); + Some(LayoutPane { + pane_id: Some(self.public_pane_id(ws_idx, pane_id)?), + label: terminal.and_then(|terminal| terminal.manual_label.clone()), + cwd: tab + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) + .map(|cwd| cwd.display().to_string()), + command: terminal.and_then(|terminal| terminal.launch_argv.clone()), + env: Default::default(), + }) + } + + fn layout_root_cwd( + &self, + ws_idx: usize, + replace_target: Option<(usize, usize)>, + pane: &LayoutPane, + ) -> PathBuf { + if let Some(cwd) = pane.cwd.as_ref() { + return PathBuf::from(cwd); + } + let follow_cwd = replace_target.and_then(|(_, tab_idx)| { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + tab.cwd_for_pane( + tab.layout.focused(), + &self.state.terminals, + &self.terminal_runtimes, + ) + }); + self.resolve_new_terminal_cwd(follow_cwd.or_else(|| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|runtime| runtime.cwd()) + })) + } + + fn apply_layout_node_to_pane( + &mut self, + ws_idx: usize, + pane_id: PaneId, + node: &LayoutNode, + ) -> Result<(), String> { + match node { + LayoutNode::Pane { pane } => { + self.apply_layout_pane_label(ws_idx, pane_id, pane); + Ok(()) + } + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + let second_leaf = first_layout_leaf(second); + let new_pane = self.layout_split_pane( + ws_idx, + pane_id, + direction.clone(), + *ratio, + second_leaf, + )?; + self.apply_layout_node_to_pane(ws_idx, pane_id, first)?; + self.apply_layout_node_to_pane(ws_idx, new_pane, second) + } + } + } + + fn layout_split_pane( + &mut self, + ws_idx: usize, + target_pane_id: PaneId, + direction: SplitDirection, + ratio: f32, + pane: &LayoutPane, + ) -> Result<PaneId, String> { + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let cwd = pane.cwd.as_ref().map(PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + let extra_env = super::env::normalize_launch_env(pane.env.clone()) + .map_err(|(_, message)| message.to_string())?; + let direction = match direction { + SplitDirection::Right => Direction::Horizontal, + SplitDirection::Down => Direction::Vertical, + }; + let command = layout_command(pane)?; + let result = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return Err("workspace not found".into()); + }; + if let Some(argv) = command.as_deref() { + ws.split_pane_argv_command_with_ratio( + target_pane_id, + direction, + ratio, + rows, + cols, + cwd, + argv, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + false, + ) + } else { + ws.split_pane_with_ratio( + target_pane_id, + direction, + ratio, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), + extra_env, + false, + ) + } + }; + let (_, new_pane) = result + .ok_or_else(|| "pane not found".to_string())? + .map_err(|err| err.to_string())?; + let new_pane_id = new_pane.pane_id; + self.attach_new_layout_pane(new_pane); + self.apply_layout_pane_label(ws_idx, new_pane_id, pane); + Ok(new_pane_id) + } + + fn attach_new_layout_pane(&mut self, new_pane: NewPane) { + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + } + + fn apply_layout_pane_label(&mut self, ws_idx: usize, pane_id: PaneId, pane: &LayoutPane) { + let Some(label) = pane + .label + .as_ref() + .map(|label| label.trim()) + .filter(|label| !label.is_empty()) + else { + return; + }; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { + return; + }; + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.set_manual_label(label.to_string()); + } + } + + fn rollback_layout_tab(&mut self, ws_idx: usize, root_pane: PaneId) { + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.iter().position(|tab| tab.root_pane == root_pane)) + else { + return; + }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + if self + .state + .workspaces + .get_mut(ws_idx) + .is_some_and(|ws| ws.close_tab(tab_idx)) + { + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + } + } +} + +fn first_layout_leaf(node: &LayoutNode) -> &LayoutPane { + match node { + LayoutNode::Pane { pane } => pane, + LayoutNode::Split { first, .. } => first_layout_leaf(first), + } +} + +fn layout_command(pane: &LayoutPane) -> Result<Option<Vec<String>>, String> { + match pane.command.as_ref() { + Some(command) if command.is_empty() => Err("pane command must not be empty".into()), + Some(command) => Ok(Some(command.clone())), + None => Ok(None), + } +} + +fn validate_layout_tree(root: &LayoutNode) -> Result<(), String> { + let mut stats = LayoutTreeStats { + panes: 0, + max_depth: 0, + }; + validate_layout_node(root, 1, &mut stats)?; + if stats.panes > MAX_LAYOUT_PANES { + return Err(format!( + "layout has {} panes; maximum is {}", + stats.panes, MAX_LAYOUT_PANES + )); + } + if stats.max_depth > MAX_LAYOUT_DEPTH { + return Err(format!( + "layout depth is {}; maximum is {}", + stats.max_depth, MAX_LAYOUT_DEPTH + )); + } + Ok(()) +} + +struct LayoutTreeStats { + panes: usize, + max_depth: usize, +} + +fn validate_layout_node( + node: &LayoutNode, + depth: usize, + stats: &mut LayoutTreeStats, +) -> Result<(), String> { + stats.max_depth = stats.max_depth.max(depth); + if depth > MAX_LAYOUT_DEPTH { + return Err(format!( + "layout depth is {}; maximum is {}", + depth, MAX_LAYOUT_DEPTH + )); + } + match node { + LayoutNode::Pane { pane } => { + stats.panes += 1; + if stats.panes > MAX_LAYOUT_PANES { + return Err(format!("layout has more than {} panes", MAX_LAYOUT_PANES)); + } + layout_command(pane)?; + super::env::normalize_launch_env(pane.env.clone()) + .map_err(|(_, message)| message.to_string())?; + Ok(()) + } + LayoutNode::Split { + first, + second, + ratio, + .. + } => { + if !ratio.is_finite() { + return Err("split ratio must be finite".into()); + } + validate_layout_node(first, depth + 1, stats)?; + validate_layout_node(second, depth + 1, stats) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + api::schema::{ErrorResponse, ResponseResult, SuccessResponse}, + config::Config, + workspace::Workspace, + }; + + fn app_with_workspace() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("layout")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + app + } + + #[test] + fn layout_export_returns_portable_tree() { + let mut app = app_with_workspace(); + let root = app.state.workspaces[0].tabs[0].root_pane; + let right = app.state.workspaces[0].test_split(Direction::Horizontal); + app.state.ensure_test_terminals(); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + app.state.workspaces[0].tabs[0] + .layout + .set_ratio_at(&[], 0.65); + let right_terminal_id = app.state.workspaces[0].tabs[0] + .terminal_id(right) + .cloned() + .unwrap(); + app.state + .terminals + .get_mut(&right_terminal_id) + .unwrap() + .set_manual_label("tests".into()); + + let response = app.handle_layout_export( + "req".into(), + LayoutExportParams { + tab_id: None, + pane_id: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutExport { layout } = success.result else { + panic!("expected layout export response"); + }; + assert_eq!(layout.workspace_id, app.public_workspace_id(0)); + assert_eq!(layout.focused_pane_id, app.public_pane_id(0, root).unwrap()); + let LayoutNode::Split { + direction, + ratio, + second, + .. + } = layout.root + else { + panic!("expected split layout root"); + }; + assert_eq!(direction, SplitDirection::Right); + assert!((ratio - 0.65).abs() < f32::EPSILON); + let LayoutNode::Pane { pane } = *second else { + panic!("expected second pane"); + }; + assert_eq!(pane.label.as_deref(), Some("tests")); + assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap())); + } + + #[tokio::test] + async fn layout_apply_replaces_tab_with_requested_tree() { + let mut app = app_with_workspace(); + let original_tab_id = app.public_tab_id(0, 0).unwrap(); + + let response = app.handle_layout_apply( + "req".into(), + LayoutApplyParams { + workspace_id: None, + tab_id: Some(original_tab_id), + tab_label: Some("dev".into()), + focus: true, + root: LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.7, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("tests".into()), + command: Some(vec!["sh".into(), "-c".into(), "true".into()]), + env: std::collections::HashMap::from([( + "HERDR_ROLE".into(), + "tests".into(), + )]), + ..Default::default() + }, + }), + }, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutApply { layout } = success.result else { + panic!("expected layout apply response"); + }; + assert_eq!(app.state.workspaces[0].tabs.len(), 1); + assert_eq!(app.state.workspaces[0].tabs[0].display_name(), "dev"); + let LayoutNode::Split { + direction, + ratio, + first, + second, + } = layout.root + else { + panic!("expected split layout root"); + }; + assert_eq!(direction, SplitDirection::Right); + assert!((ratio - 0.7).abs() < f32::EPSILON); + let LayoutNode::Pane { pane: first_pane } = *first else { + panic!("expected first pane"); + }; + let LayoutNode::Pane { pane: second_pane } = *second else { + panic!("expected second pane"); + }; + assert_eq!(first_pane.label.as_deref(), Some("editor")); + assert_eq!(second_pane.label.as_deref(), Some("tests")); + assert_eq!( + second_pane.command, + Some(vec!["sh".into(), "-c".into(), "true".into()]) + ); + } + + #[tokio::test] + async fn layout_apply_rejects_invalid_deep_leaf_without_creating_tab() { + let mut app = app_with_workspace(); + let original_tab_count = app.state.workspaces[0].tabs.len(); + + let response = app.handle_layout_apply( + "req".into(), + LayoutApplyParams { + workspace_id: Some(app.public_workspace_id(0)), + tab_id: None, + tab_label: Some("bad".into()), + focus: false, + root: LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.5, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + command: Some(Vec::new()), + ..Default::default() + }, + }), + }, + }, + ); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "invalid_layout"); + assert_eq!(app.state.workspaces[0].tabs.len(), original_tab_count); + } + + #[test] + fn layout_validation_rejects_too_many_panes() { + let mut root = LayoutNode::Pane { + pane: LayoutPane::default(), + }; + for _ in 0..MAX_LAYOUT_PANES { + root = LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.5, + first: Box::new(root), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane::default(), + }), + }; + } + + let err = validate_layout_tree(&root).unwrap_err(); + assert!(err.contains("maximum")); + } +} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 7ef4ed3c..2d994dca 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1,16 +1,17 @@ use bytes::Bytes; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection, - PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason, - PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, - PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, PaneMoveParams, - PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, PaneReadParams, - PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, - PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, - PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, - PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, - PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult, + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams, + PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, + PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, + PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, + PaneMoveParams, PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, + PaneProcessInfo, PaneProcessInfoParams, PaneProcessInfoProcess, PaneReadParams, PaneReadResult, + PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, + PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, + PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, + PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, + PaneZoomResult, ReadFormat, ReadSource, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::{App, Mode}; @@ -22,6 +23,10 @@ use super::super::api_helpers::{ }; use super::responses::{encode_error, encode_success}; +const METADATA_SOURCE_MAX_CHARS: usize = 80; +const METADATA_TTL_MIN_MS: u64 = 1; +const METADATA_TTL_MAX_MS: u64 = 86_400_000; + impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { @@ -40,6 +45,10 @@ impl App { let Some((ws_idx, target_pane_id)) = target else { return encode_error(id, "pane_not_found", "pane not found"); }; + let extra_env = match super::env::normalize_launch_env(params.env) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; let (rows, cols) = self.state.estimate_pane_size(); let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| { @@ -75,6 +84,7 @@ impl App { scrollback_limit_bytes, host_terminal_theme, shell_config, + extra_env, params.focus, ), None => ws.split_pane( @@ -86,6 +96,7 @@ impl App { scrollback_limit_bytes, host_terminal_theme, shell_config, + extra_env, params.focus, ), }; @@ -124,6 +135,21 @@ impl App { } } + pub(super) fn handle_pane_current(&mut self, id: String, params: PaneCurrentParams) -> String { + let target = match params.caller_pane_id.as_deref() { + Some(caller_pane_id) => self.parse_pane_id(caller_pane_id), + None => self.resolve_optional_pane(None), + }; + let Some((ws_idx, pane_id)) = target else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + + encode_success(id, ResponseResult::PaneCurrent { pane }) + } + pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); @@ -152,6 +178,54 @@ impl App { encode_success(id, ResponseResult::PaneLayout { layout }) } + pub(super) fn handle_pane_process_info( + &mut self, + id: String, + params: PaneProcessInfoParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let shell_pid = runtime.child_pid(); + let foreground_job = shell_pid.and_then(crate::detect::foreground_job); + let foreground_process_group_id = foreground_job.as_ref().map(|job| job.process_group_id); + let foreground_processes = foreground_job + .map(|job| { + job.processes + .into_iter() + .map(|process| PaneProcessInfoProcess { + pid: process.pid, + name: process.name, + argv0: process.argv0, + argv: process.argv, + cmdline: process.cmdline, + cwd: crate::platform::process_cwd(process.pid) + .map(|cwd| cwd.display().to_string()), + }) + .collect() + }) + .unwrap_or_default(); + + encode_success( + id, + ResponseResult::PaneProcessInfo { + process_info: PaneProcessInfo { + pane_id: public_pane_id, + shell_pid, + foreground_process_group_id, + tty: None, + foreground_processes, + }, + }, + ) + } + pub(super) fn handle_pane_neighbor( &mut self, id: String, @@ -910,6 +984,7 @@ impl App { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id: closed_workspace_id.clone(), + workspace: None, }, }); } @@ -1184,29 +1259,26 @@ impl App { }, None => None, }; - let Some(source) = normalize_optional_text(Some(params.source)) else { - return encode_error(id, "invalid_metadata_request", "missing metadata source"); + let source = match normalize_metadata_source(params.source) { + Ok(source) => source, + Err(message) => return encode_error(id, "invalid_metadata_source", message), }; let raw_title_set = params.title.is_some(); let raw_display_agent_set = params.display_agent.is_some(); let raw_custom_status_set = params.custom_status.is_some(); let raw_state_labels_set = !params.state_labels.is_empty(); - let ttl = params.ttl_ms.map(std::time::Duration::from_millis); + let ttl = match normalize_metadata_ttl(params.ttl_ms) { + Ok(ttl) => ttl, + Err(message) => return encode_error(id, "invalid_metadata_ttl", message), + }; let title = normalize_presentation_text(params.title); let display_agent = normalize_presentation_text(params.display_agent); let custom_status = normalize_custom_status(params.custom_status); let applies_to_source = match params.applies_to_source { - Some(applies_to_source) => { - let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source)) - else { - return encode_error( - id, - "invalid_metadata_request", - "missing metadata authority source", - ); - }; - Some(applies_to_source) - } + Some(applies_to_source) => match normalize_metadata_source(applies_to_source) { + Ok(applies_to_source) => Some(applies_to_source), + Err(message) => return encode_error(id, "invalid_metadata_source", message), + }, None => None, }; let state_labels = match normalize_state_labels(params.state_labels) { @@ -1353,29 +1425,39 @@ impl App { } pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String { + match self.close_pane(id.clone(), &target) { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(response) => response, + } + } + + /// Close a pane; `Err` carries the encoded error response. + pub(super) fn close_pane(&mut self, id: String, target: &PaneTarget) -> Result<(), String> { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return pane_not_found(id, &target.pane_id); + return Err(pane_not_found(id, &target.pane_id)); }; let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return pane_not_found(id, &target.pane_id); + return Err(pane_not_found(id, &target.pane_id)); }; let workspace_id = self.public_workspace_id(ws_idx); if self.state.close_pane_would_close_workspace(ws_idx, pane_id) && self.state.confirm_implicit_worktree_group_close(ws_idx) { - return encode_error( + return Err(encode_error( id, "confirmation_required", "closing this pane would close a worktree group", - ); + )); } + let workspace_snapshot = self.workspace_info(ws_idx); let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return pane_not_found(id, &target.pane_id); + return Err(pane_not_found(id, &target.pane_id)); }; ws.close_pane(pane_id) }; + self.state.plugin_panes.remove(&pane_id); if should_close_workspace { self.state.selected = ws_idx; self.state.close_selected_workspace(); @@ -1389,7 +1471,10 @@ impl App { }); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, - data: EventData::WorkspaceClosed { workspace_id }, + data: EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace_snapshot), + }, }); } else { self.state.remove_unattached_terminal_ids(terminal_id); @@ -1404,7 +1489,7 @@ impl App { }); } - encode_success(id, ResponseResult::Ok {}) + Ok(()) } pub(super) fn handle_pane_send_keys( @@ -1432,9 +1517,39 @@ impl App { } } -fn normalize_optional_text(value: Option<String>) -> Option<String> { - let value = value?.trim().to_string(); - (!value.is_empty()).then_some(value) +fn normalize_metadata_source(value: String) -> Result<String, &'static str> { + let value = value.trim(); + if value.is_empty() { + return Err("metadata source must not be empty"); + } + if value.chars().count() > METADATA_SOURCE_MAX_CHARS { + return Err("metadata source must be 80 characters or fewer"); + } + if !value.chars().all(metadata_source_char_is_valid) { + return Err( + "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", + ); + } + Ok(value.to_string()) +} + +fn metadata_source_char_is_valid(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-') +} + +fn normalize_metadata_ttl( + ttl_ms: Option<u64>, +) -> Result<Option<std::time::Duration>, &'static str> { + let Some(ttl_ms) = ttl_ms else { + return Ok(None); + }; + if ttl_ms < METADATA_TTL_MIN_MS { + return Err("metadata ttl_ms must be at least 1"); + } + if ttl_ms > METADATA_TTL_MAX_MS { + return Err("metadata ttl_ms must be 86400000 or less"); + } + Ok(Some(std::time::Duration::from_millis(ttl_ms))) } fn normalize_presentation_text(value: Option<String>) -> Option<String> { @@ -1659,11 +1774,51 @@ fn invalid_agent(id: String) -> String { mod tests { use super::*; use crate::{ - api::schema::{SplitDirection, SuccessResponse}, + api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, workspace::Workspace, }; + fn app_with_test_workspace() -> (App, String) { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("metadata")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); + (app, public_pane_id) + } + + fn metadata_params(pane_id: String) -> PaneReportMetadataParams { + PaneReportMetadataParams { + pane_id, + source: "user:metadata.test-1".into(), + agent: None, + applies_to_source: None, + title: None, + display_agent: None, + custom_status: Some("activity".into()), + state_labels: std::collections::HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_custom_status: false, + clear_state_labels: false, + seq: None, + ttl_ms: None, + } + } + + fn metadata_error_code(response: &str) -> String { + let response: ErrorResponse = serde_json::from_str(response).unwrap(); + response.error.code + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -1674,6 +1829,7 @@ mod tests { crate::api::EventHub::default(), ); app.state.workspaces = vec![Workspace::test_new("issue")]; + app.state.ensure_test_terminals(); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), @@ -1722,6 +1878,106 @@ mod tests { } #[test] + fn api_pane_current_prefers_caller_pane_id() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + app.state.ensure_test_terminals(); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + let root_public = app.public_pane_id(0, root).unwrap(); + let right_public = app.public_pane_id(0, right).unwrap(); + + let response = app.handle_pane_current( + "req".into(), + crate::api::schema::PaneCurrentParams { + caller_pane_id: Some(right_public.clone()), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCurrent { pane } = success.result else { + panic!("expected pane current response"); + }; + assert_eq!(pane.pane_id, right_public); + assert!(!pane.focused); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + assert_ne!(pane.pane_id, root_public); + } + + #[test] + fn api_pane_current_falls_back_to_focused_pane() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + let root_public = app.public_pane_id(0, root).unwrap(); + + let response = app.handle_pane_current( + "req".into(), + crate::api::schema::PaneCurrentParams::default(), + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCurrent { pane } = success.result else { + panic!("expected pane current response"); + }; + assert_eq!(pane.pane_id, root_public); + assert!(pane.focused); + } + + #[test] + fn api_pane_current_dispatches_through_socket_request() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let root_public = app.public_pane_id(0, root).unwrap(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneCurrent( + crate::api::schema::PaneCurrentParams::default(), + ), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCurrent { pane } = success.result else { + panic!("expected pane current response"); + }; + assert_eq!(pane.pane_id, root_public); + } + + #[test] + fn api_pane_current_reports_invalid_caller_pane_id() { + let mut app = app_with_linked_worktree(); + + let response = app.handle_pane_current( + "req".into(), + crate::api::schema::PaneCurrentParams { + caller_pane_id: Some("missing".into()), + }, + ); + + assert_eq!(metadata_error_code(&response), "pane_not_found"); + } + + #[test] + fn api_pane_current_reports_no_active_pane() { + let mut app = app_with_linked_worktree(); + app.state.active = None; + + let response = app.handle_pane_current( + "req".into(), + crate::api::schema::PaneCurrentParams::default(), + ); + + assert_eq!(metadata_error_code(&response), "pane_not_found"); + } + + #[test] fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; @@ -2825,4 +3081,64 @@ mod tests { assert_eq!(focus.focused_pane_id, Some(root_public)); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); } + + #[test] + fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut params = metadata_params(pane_id); + params.ttl_ms = Some(METADATA_TTL_MAX_MS); + + let response = app.handle_pane_report_metadata("req".into(), params); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + } + + #[test] + fn pane_report_metadata_rejects_invalid_source_shape() { + let (mut app, pane_id) = app_with_test_workspace(); + for source in ["", "user metadata", "user/metadata", "user:\u{7f}metadata"] { + let mut params = metadata_params(pane_id.clone()); + params.source = source.into(); + + let response = app.handle_pane_report_metadata("req".into(), params); + + assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); + } + } + + #[test] + fn pane_report_metadata_rejects_long_source() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut params = metadata_params(pane_id); + params.source = "a".repeat(METADATA_SOURCE_MAX_CHARS + 1); + + let response = app.handle_pane_report_metadata("req".into(), params); + + assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); + } + + #[test] + fn pane_report_metadata_rejects_invalid_applies_to_source() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut params = metadata_params(pane_id); + params.applies_to_source = Some("herdr source".into()); + + let response = app.handle_pane_report_metadata("req".into(), params); + + assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); + } + + #[test] + fn pane_report_metadata_rejects_ttl_outside_supported_range() { + let (mut app, pane_id) = app_with_test_workspace(); + for ttl_ms in [0, METADATA_TTL_MAX_MS + 1] { + let mut params = metadata_params(pane_id.clone()); + params.ttl_ms = Some(ttl_ms); + + let response = app.handle_pane_report_metadata("req".into(), params); + + assert_eq!(metadata_error_code(&response), "invalid_metadata_ttl"); + } + } } diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs new file mode 100644 index 00000000..881020b8 --- /dev/null +++ b/src/app/api/plugins/context.rs @@ -0,0 +1,379 @@ +use crate::api::schema::{EventData, PluginInvocationContext}; +use crate::app::App; + +impl App { + pub(super) fn merge_plugin_context( + &self, + provided: Option<PluginInvocationContext>, + correlation_id: &str, + ) -> PluginInvocationContext { + let mut context = self.current_plugin_context(correlation_id); + if let Some(provided) = provided { + context.workspace_id = provided.workspace_id.or(context.workspace_id); + context.workspace_label = provided.workspace_label.or(context.workspace_label); + context.workspace_cwd = provided.workspace_cwd.or(context.workspace_cwd); + context.worktree = provided.worktree.or(context.worktree); + context.tab_id = provided.tab_id.or(context.tab_id); + context.tab_label = provided.tab_label.or(context.tab_label); + context.focused_pane_id = provided.focused_pane_id.or(context.focused_pane_id); + context.focused_pane_cwd = provided.focused_pane_cwd.or(context.focused_pane_cwd); + context.focused_pane_agent = provided.focused_pane_agent.or(context.focused_pane_agent); + context.focused_pane_status = + provided.focused_pane_status.or(context.focused_pane_status); + context.selected_text = provided.selected_text.or(context.selected_text); + context.invocation_source = provided.invocation_source.or(context.invocation_source); + context.correlation_id = provided.correlation_id.or(context.correlation_id); + context.clicked_url = provided.clicked_url.or(context.clicked_url); + context.link_handler_id = provided.link_handler_id.or(context.link_handler_id); + } + context + } + + pub(super) fn current_plugin_context(&self, correlation_id: &str) -> PluginInvocationContext { + let Some(ws_idx) = self.state.active else { + return empty_plugin_context(correlation_id); + }; + self.plugin_context_for_workspace(ws_idx, correlation_id) + } + + pub(super) fn plugin_context_for_event( + &self, + event: &crate::api::schema::EventEnvelope, + correlation_id: &str, + ) -> PluginInvocationContext { + match &event.data { + EventData::WorkspaceCreated { workspace } + | EventData::WorkspaceUpdated { workspace } + | EventData::WorktreeCreated { workspace, .. } + | EventData::WorktreeOpened { workspace, .. } => { + self.plugin_context_for_workspace_info(workspace, correlation_id) + } + EventData::WorkspaceClosed { + workspace_id, + workspace, + } => workspace + .as_ref() + .map(|workspace| self.plugin_context_for_workspace_info(workspace, correlation_id)) + .unwrap_or_else(|| { + self.plugin_context_for_workspace_id(workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context + }) + }), + EventData::WorkspaceRenamed { workspace_id, .. } + | EventData::WorkspaceFocused { workspace_id } + | EventData::WorktreeRemoved { workspace_id, .. } => self + .plugin_context_for_workspace_id(workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context + }), + EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id), + EventData::TabClosed { + tab_id, + workspace_id, + } => { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.tab_id = Some(tab_id.clone()); + context + } + EventData::TabRenamed { + tab_id, + workspace_id, + .. + } + | EventData::TabFocused { + tab_id, + workspace_id, + } => self + .plugin_context_for_tab_id(tab_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.tab_id = Some(tab_id.clone()); + context + }), + EventData::PaneCreated { pane } => { + self.plugin_context_for_pane_info(pane, correlation_id) + } + EventData::PaneMoved { pane, .. } => { + self.plugin_context_for_pane_info(pane.as_ref(), correlation_id) + } + EventData::PaneClosed { + pane_id, + workspace_id, + } => { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.focused_pane_id = Some(pane_id.clone()); + context + } + EventData::PaneFocused { + pane_id, + workspace_id, + } + | EventData::PaneOutputChanged { + pane_id, + workspace_id, + .. + } + | EventData::PaneExited { + pane_id, + workspace_id, + } + | EventData::PaneAgentDetected { + pane_id, + workspace_id, + .. + } + | EventData::PaneAgentStatusChanged { + pane_id, + workspace_id, + .. + } => self + .plugin_context_for_public_pane_id(pane_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.focused_pane_id = Some(pane_id.clone()); + context + }), + } + } + + fn plugin_context_for_workspace_id( + &self, + workspace_id: &str, + correlation_id: &str, + ) -> Option<PluginInvocationContext> { + let ws_idx = self + .state + .workspaces + .iter() + .enumerate() + .find_map(|(idx, _)| (self.public_workspace_id(idx) == workspace_id).then_some(idx))?; + Some(self.plugin_context_for_workspace(ws_idx, correlation_id)) + } + + fn plugin_context_for_workspace_info( + &self, + workspace: &crate::api::schema::WorkspaceInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace.workspace_id.clone()); + context.workspace_label = Some(workspace.label.clone()); + context.workspace_cwd = workspace + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.clone()); + context.worktree = workspace.worktree.clone(); + context.tab_id = Some(workspace.active_tab_id.clone()); + context + }) + } + + fn plugin_context_for_tab_id( + &self, + tab_id: &str, + correlation_id: &str, + ) -> Option<PluginInvocationContext> { + let (ws_idx, tab_idx) = self.parse_tab_id(tab_id)?; + let ws = self.state.workspaces.get(ws_idx)?; + let workspace = self.workspace_info(ws_idx); + let tab = ws.tabs.get(tab_idx)?; + let pane_id = tab.layout.focused(); + let focused_pane = self.pane_info(ws_idx, pane_id); + Some(self.plugin_context_from_parts( + ws_idx, + workspace, + self.public_tab_id(ws_idx, tab_idx), + Some(tab.display_name()), + focused_pane, + correlation_id, + )) + } + + fn plugin_context_for_tab_info( + &self, + tab: &crate::api::schema::TabInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_tab_id(&tab.tab_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(&tab.workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(tab.workspace_id.clone()); + context.tab_id = Some(tab.tab_id.clone()); + context.tab_label = Some(tab.label.clone()); + context + }) + } + + fn plugin_context_for_public_pane_id( + &self, + pane_id: &str, + correlation_id: &str, + ) -> Option<PluginInvocationContext> { + let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?; + Some(self.plugin_context_for_pane(ws_idx, pane_id, correlation_id)) + } + + fn plugin_context_for_pane_info( + &self, + pane: &crate::api::schema::PaneInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_public_pane_id(&pane.pane_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(&pane.workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(pane.workspace_id.clone()); + context.tab_id = Some(pane.tab_id.clone()); + context.focused_pane_id = Some(pane.pane_id.clone()); + context.focused_pane_cwd = pane.cwd.clone(); + context.focused_pane_agent = pane.agent.clone(); + context.focused_pane_status = Some(pane.agent_status); + context + }) + } + + pub(super) fn plugin_context_for_workspace( + &self, + ws_idx: usize, + correlation_id: &str, + ) -> PluginInvocationContext { + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return empty_plugin_context(correlation_id); + }; + let workspace = self.workspace_info(ws_idx); + let tab_idx = ws.active_tab_index(); + let tab_id = self.public_tab_id(ws_idx, tab_idx); + let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let focused_pane = ws + .focused_pane_id() + .and_then(|pane_id| self.pane_info(ws_idx, pane_id)); + self.plugin_context_from_parts( + ws_idx, + workspace, + tab_id, + tab_label, + focused_pane, + correlation_id, + ) + } + + pub(super) fn plugin_context_for_pane( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + correlation_id: &str, + ) -> PluginInvocationContext { + let ws = &self.state.workspaces[ws_idx]; + let workspace = self.workspace_info(ws_idx); + let tab_idx = ws + .find_tab_index_for_pane(pane_id) + .unwrap_or_else(|| ws.active_tab_index()); + let tab_id = self.public_tab_id(ws_idx, tab_idx); + let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let focused_pane = self.pane_info(ws_idx, pane_id); + self.plugin_context_from_parts( + ws_idx, + workspace, + tab_id, + tab_label, + focused_pane, + correlation_id, + ) + } + + fn plugin_context_from_parts( + &self, + ws_idx: usize, + workspace: crate::api::schema::WorkspaceInfo, + tab_id: Option<String>, + tab_label: Option<String>, + focused_pane: Option<crate::api::schema::PaneInfo>, + correlation_id: &str, + ) -> PluginInvocationContext { + let workspace_cwd = focused_pane + .as_ref() + .and_then(|pane| pane.cwd.clone()) + .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); + let selected_text = focused_pane + .as_ref() + .and_then(|pane| self.parse_pane_id(&pane.pane_id)) + .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); + PluginInvocationContext { + workspace_id: Some(workspace.workspace_id), + workspace_label: Some(workspace.label), + workspace_cwd, + worktree: workspace.worktree, + tab_id, + tab_label, + focused_pane_id: focused_pane.as_ref().map(|pane| pane.pane_id.clone()), + focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), + focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), + focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), + selected_text, + invocation_source: Some("api".to_string()), + correlation_id: Some(correlation_id.to_string()), + clicked_url: None, + link_handler_id: None, + } + } + + fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> { + let selection = self.state.selection.as_ref()?; + if selection.pane_id != pane_id || !selection.is_visible() { + return None; + } + let terminal_id = self + .state + .workspaces + .iter() + .find_map(|workspace| workspace.terminal_id(pane_id))?; + self.terminal_runtimes + .get(terminal_id) + .and_then(|runtime| runtime.extract_selection(selection)) + .filter(|text| !text.is_empty()) + } + + fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { + self.state + .workspaces + .get(ws_idx) + .and_then(|ws| { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + }) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())) + } +} + +fn empty_plugin_context(correlation_id: &str) -> PluginInvocationContext { + PluginInvocationContext { + workspace_id: None, + workspace_label: None, + workspace_cwd: None, + worktree: None, + tab_id: None, + tab_label: None, + focused_pane_id: None, + focused_pane_cwd: None, + focused_pane_agent: None, + focused_pane_status: None, + selected_text: None, + invocation_source: Some("api".to_string()), + correlation_id: Some(correlation_id.to_string()), + clicked_url: None, + link_handler_id: None, + } +} diff --git a/src/app/api/plugins/env.rs b/src/app/api/plugins/env.rs new file mode 100644 index 00000000..13644a19 --- /dev/null +++ b/src/app/api/plugins/env.rs @@ -0,0 +1,19 @@ +use crate::api::schema::InstalledPluginInfo; + +pub(super) fn plugin_path_env(plugin: &InstalledPluginInfo) -> Vec<(String, String)> { + let component = crate::api::schema::plugin_managed_path_component(&plugin.plugin_id); + let config_dir = crate::config::config_dir().join("plugins").join(&component); + let state_dir = crate::config::state_dir().join("plugins").join(component); + + vec![ + ("HERDR_PLUGIN_ROOT".to_string(), plugin.plugin_root.clone()), + ( + "HERDR_PLUGIN_CONFIG_DIR".to_string(), + config_dir.display().to_string(), + ), + ( + "HERDR_PLUGIN_STATE_DIR".to_string(), + state_dir.display().to_string(), + ), + ] +} diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs new file mode 100644 index 00000000..92799aae --- /dev/null +++ b/src/app/api/plugins/manifest.rs @@ -0,0 +1,539 @@ +use crate::api::schema::{ + InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook, + PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, + PluginSourceInfo, PluginSourceKind, +}; + +const PLUGIN_ID_MAX_CHARS: usize = 120; +const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; + +#[derive(serde::Deserialize)] +struct RawPluginManifest { + id: String, + name: String, + version: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + #[serde(default)] + build: Vec<RawPluginManifestBuild>, + #[serde(default)] + actions: Vec<RawPluginManifestAction>, + #[serde(default)] + events: Vec<RawPluginManifestEventHook>, + #[serde(default)] + panes: Vec<RawPluginManifestPane>, + #[serde(default)] + link_handlers: Vec<RawPluginManifestLinkHandler>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestBuild { + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestAction { + id: String, + title: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + contexts: Vec<crate::api::schema::PluginActionContext>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestEventHook { + on: String, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestPane { + id: String, + title: String, + #[serde(default)] + description: Option<String>, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, + #[serde(default)] + placement: PluginPanePlacement, + command: Vec<String>, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestLinkHandler { + id: String, + title: String, + pattern: String, + action: String, + #[serde(default)] + platforms: Option<Vec<RawPlatform>>, +} + +/// Raw string platform value from the manifest, validated before conversion. +#[derive(serde::Deserialize)] +#[serde(try_from = "String")] +struct RawPlatform(PluginPlatform); + +impl TryFrom<String> for RawPlatform { + type Error = String; + + fn try_from(value: String) -> Result<Self, Self::Error> { + match value.as_str() { + "linux" => Ok(RawPlatform(PluginPlatform::Linux)), + "macos" => Ok(RawPlatform(PluginPlatform::Macos)), + "windows" => Ok(RawPlatform(PluginPlatform::Windows)), + other => Err(format!( + "invalid_plugin_platform: unknown platform '{other}'" + )), + } + } +} + +pub(crate) fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result<InstalledPluginInfo, (&'static str, String)> { + let path = std::path::PathBuf::from(path); + let manifest_path = if path.is_dir() { + path.join("herdr-plugin.toml") + } else { + path + }; + let manifest_path = manifest_path + .canonicalize() + .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?; + let plugin_root = manifest_path + .parent() + .ok_or_else(|| { + ( + "invalid_plugin_manifest_path", + "manifest path has no parent directory".to_string(), + ) + })? + .to_path_buf(); + let content = std::fs::read_to_string(&manifest_path) + .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?; + let raw: RawPluginManifest = toml::from_str(&content) + .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?; + let plugin_id = normalize_plugin_id(&raw.id) + .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; + let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?; + let version = non_empty_trimmed( + &raw.version, + "invalid_plugin_version", + "plugin version is required", + )?; + let description = raw + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(raw.platforms)?; + let build = raw + .build + .into_iter() + .map(normalize_manifest_build) + .collect::<Result<Vec<_>, _>>()?; + let mut actions = raw + .actions + .into_iter() + .map(normalize_manifest_action) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_action_ids(&actions)?; + actions.sort_by(|a, b| a.id.cmp(&b.id)); + let mut events = raw + .events + .into_iter() + .map(normalize_manifest_event) + .collect::<Result<Vec<_>, _>>()?; + events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command))); + let mut panes = raw + .panes + .into_iter() + .map(normalize_manifest_pane) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_pane_ids(&panes)?; + panes.sort_by(|a, b| a.id.cmp(&b.id)); + let link_handlers = raw + .link_handlers + .into_iter() + .map(normalize_manifest_link_handler) + .collect::<Result<Vec<_>, _>>()?; + reject_duplicate_link_handler_ids(&link_handlers)?; + validate_link_handler_actions(&link_handlers, &actions)?; + + let mut warnings = validate_event_names(&events); + if platforms.is_none() { + warnings.push("manifest does not declare platforms; platform support unknown".to_string()); + } + + Ok(InstalledPluginInfo { + plugin_id, + name, + version, + description, + manifest_path: manifest_path.display().to_string(), + plugin_root: plugin_root.display().to_string(), + enabled, + platforms, + build, + actions, + events, + panes, + link_handlers, + source: Default::default(), + warnings, + }) +} + +fn normalize_manifest_build( + build: RawPluginManifestBuild, +) -> Result<PluginManifestBuild, (&'static str, String)> { + let platforms = normalize_platforms(build.platforms)?; + let command = normalize_command(build.command)?; + Ok(PluginManifestBuild { platforms, command }) +} + +pub(super) fn normalize_plugin_source( + plugin: &InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result<PluginSourceInfo, (&'static str, String)> { + if source.kind == PluginSourceKind::Local { + return Ok(source); + } + let Some(managed_path) = source.managed_path.as_deref() else { + return Err(( + "invalid_plugin_source", + "GitHub plugin source requires managed_path".to_string(), + )); + }; + let managed_path = std::path::PathBuf::from(managed_path) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let expected = crate::session::data_dir() + .join("plugins") + .join("github") + .join(crate::api::schema::plugin_managed_path_component( + &plugin.plugin_id, + )) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + if managed_path != expected { + return Err(( + "invalid_plugin_source", + "GitHub plugin managed_path does not match the plugin id".to_string(), + )); + } + if !plugin_root.starts_with(&managed_path) { + return Err(( + "invalid_plugin_source", + "plugin manifest is not inside the managed checkout".to_string(), + )); + } + Ok(source) +} + +fn reject_duplicate_action_ids( + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for action in actions { + if !seen.insert(action.id.as_str()) { + return Err(( + "duplicate_plugin_action_id", + format!("duplicate action id '{}'", action.id), + )); + } + } + Ok(()) +} + +fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec<String> { + let known = crate::api::schema::plugin_hook_event_names(); + events + .iter() + .filter(|hook| !known.contains(&hook.on.as_str())) + .map(|hook| format!("unknown event '{}'", hook.on)) + .collect() +} + +fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for pane in panes { + if !seen.insert(pane.id.as_str()) { + return Err(( + "duplicate_plugin_pane_id", + format!("duplicate pane id '{}'", pane.id), + )); + } + } + Ok(()) +} + +fn reject_duplicate_link_handler_ids( + handlers: &[PluginManifestLinkHandler], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for handler in handlers { + if !seen.insert(handler.id.as_str()) { + return Err(( + "duplicate_plugin_link_handler_id", + format!("duplicate link handler id '{}'", handler.id), + )); + } + } + Ok(()) +} + +fn validate_link_handler_actions( + handlers: &[PluginManifestLinkHandler], + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + for handler in handlers { + if !actions.iter().any(|action| action.id == handler.action) { + return Err(( + "invalid_plugin_link_handler_action", + format!( + "link handler '{}' references unknown action '{}'", + handler.id, handler.action + ), + )); + } + } + Ok(()) +} + +fn normalize_manifest_action( + action: RawPluginManifestAction, +) -> Result<PluginManifestAction, (&'static str, String)> { + let id = normalize_action_id(&action.id) + .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; + let title = non_empty_trimmed( + &action.title, + "invalid_plugin_action_title", + "action title is required", + )?; + let description = action + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(action.platforms)?; + let command = normalize_command(action.command)?; + Ok(PluginManifestAction { + id, + title, + description, + contexts: action.contexts, + platforms, + command, + }) +} + +fn normalize_manifest_pane( + pane: RawPluginManifestPane, +) -> Result<PluginManifestPane, (&'static str, String)> { + let id = normalize_action_id(&pane.id) + .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?; + let title = non_empty_trimmed( + &pane.title, + "invalid_plugin_pane_title", + "pane title is required", + )?; + let description = pane + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(pane.platforms)?; + let command = normalize_command(pane.command)?; + Ok(PluginManifestPane { + id, + title, + description, + platforms, + placement: pane.placement, + command, + }) +} + +fn normalize_manifest_event( + event: RawPluginManifestEventHook, +) -> Result<PluginManifestEventHook, (&'static str, String)> { + let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?; + let platforms = normalize_platforms(event.platforms)?; + let command = normalize_command(event.command)?; + Ok(PluginManifestEventHook { + on, + platforms, + command, + }) +} + +fn normalize_manifest_link_handler( + handler: RawPluginManifestLinkHandler, +) -> Result<PluginManifestLinkHandler, (&'static str, String)> { + let id = normalize_action_id(&handler.id).ok_or_else(|| { + ( + "invalid_plugin_link_handler_id", + "invalid link handler id".to_string(), + ) + })?; + let title = non_empty_trimmed( + &handler.title, + "invalid_plugin_link_handler_title", + "link handler title is required", + )?; + let pattern = non_empty_trimmed( + &handler.pattern, + "invalid_plugin_link_handler_pattern", + "link handler pattern is required", + )?; + regex::Regex::new(&pattern) + .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?; + let action = normalize_action_id(&handler.action).ok_or_else(|| { + ( + "invalid_plugin_link_handler_action", + "invalid link handler action".to_string(), + ) + })?; + let platforms = normalize_platforms(handler.platforms)?; + Ok(PluginManifestLinkHandler { + id, + title, + pattern, + action, + platforms, + }) +} + +fn normalize_platforms( + raw: Option<Vec<RawPlatform>>, +) -> Result<Option<Vec<PluginPlatform>>, (&'static str, String)> { + match raw { + None => Ok(None), + Some(list) if list.is_empty() => Err(( + "invalid_plugin_platform", + "platforms must not be an empty array; omit the field to leave platforms undeclared" + .to_string(), + )), + Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())), + } +} + +/// Returns the platform the current binary was compiled for. +fn current_platform() -> PluginPlatform { + if cfg!(target_os = "linux") { + PluginPlatform::Linux + } else if cfg!(target_os = "macos") { + PluginPlatform::Macos + } else { + PluginPlatform::Windows + } +} + +/// Resolve the effective platforms for an action or event: use the item's own +/// platforms if declared, otherwise inherit from the plugin-level platforms. +/// Returns a reference to whichever `Option<Vec<PluginPlatform>>` applies. +pub(super) fn effective_platforms<'a>( + item_platforms: &'a Option<Vec<PluginPlatform>>, + plugin_platforms: &'a Option<Vec<PluginPlatform>>, +) -> &'a Option<Vec<PluginPlatform>> { + if item_platforms.is_some() { + item_platforms + } else { + plugin_platforms + } +} + +pub(super) fn ensure_platform_supported( + platforms: &Option<Vec<PluginPlatform>>, + subject: &str, +) -> Result<(), (&'static str, String)> { + if let Some(platforms) = platforms { + let host = current_platform(); + if !platforms.contains(&host) { + return Err(( + "platform_unsupported", + format!( + "{subject} does not support the current platform ({})", + platform_name(host) + ), + )); + } + } + Ok(()) +} + +fn platform_name(p: PluginPlatform) -> &'static str { + match p { + PluginPlatform::Linux => "linux", + PluginPlatform::Macos => "macos", + PluginPlatform::Windows => "windows", + } +} + +fn normalize_command(command: Vec<String>) -> Result<Vec<String>, (&'static str, String)> { + let command = command + .into_iter() + .map(|arg| arg.trim().to_string()) + .collect::<Vec<_>>(); + if command.is_empty() || command.iter().any(|arg| arg.is_empty()) { + return Err(( + "invalid_plugin_command", + "command must contain non-empty argv strings".to_string(), + )); + } + Ok(command) +} + +fn non_empty_trimmed( + value: &str, + code: &'static str, + message: &'static str, +) -> Result<String, (&'static str, String)> { + let value = value.trim().to_string(); + if value.is_empty() { + Err((code, message.to_string())) + } else { + Ok(value) + } +} + +pub(super) fn normalize_plugin_id(value: &str) -> Option<String> { + normalize_identifier(value, PLUGIN_ID_MAX_CHARS) +} + +pub(super) fn normalize_action_id(value: &str) -> Option<String> { + normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS) +} + +fn normalize_identifier(value: &str, max_chars: usize) -> Option<String> { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-'))) + .then(|| value.to_string()) +} + +fn normalize_local_identifier(value: &str, max_chars: usize) -> Option<String> { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))) + .then(|| value.to_string()) +} diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs new file mode 100644 index 00000000..3304a256 --- /dev/null +++ b/src/app/api/plugins/mod.rs @@ -0,0 +1,2704 @@ +mod context; +mod env; +mod manifest; +mod panes; +mod runtime; + +use super::responses::{encode_error, encode_success}; +use crate::api::schema::{ + InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, + PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, + PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, + PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, + ResponseResult, +}; +use crate::app::App; +use manifest::{ + effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id, + normalize_plugin_source, +}; + +#[cfg(test)] +use crate::api::schema::{PluginCommandStatus, PluginInvocationContext}; +pub(crate) use manifest::load_plugin_manifest; +#[cfg(test)] +use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT}; + +impl App { + pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { + let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { + Ok(plugin) => plugin, + Err((code, message)) => return encode_error(id, code, message), + }; + if let Some(source) = params.source { + match normalize_plugin_source(&plugin, source) { + Ok(source) => plugin.source = source, + Err((code, message)) => return encode_error(id, code, message), + } + } + let previous = self.state.installed_plugins.get(&plugin.plugin_id).cloned(); + self.state + .installed_plugins + .insert(plugin.plugin_id.clone(), plugin.clone()); + if let Err(err) = self.save_plugin_registry() { + match previous { + Some(previous) => { + self.state + .installed_plugins + .insert(previous.plugin_id.clone(), previous); + } + None => { + self.state.installed_plugins.remove(&plugin.plugin_id); + } + } + return encode_error(id, "plugin_registry_save_failed", err.to_string()); + } + encode_success(id, ResponseResult::PluginLinked { plugin }) + } + + pub(super) fn handle_plugin_list(&mut self, id: String, params: PluginListParams) -> String { + let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { + Ok(plugin_id) => plugin_id, + Err(response) => return response, + }; + let mut plugins = self + .state + .installed_plugins + .values() + .filter(|plugin| { + plugin_id + .as_deref() + .is_none_or(|plugin_id| plugin.plugin_id == plugin_id) + }) + .cloned() + .collect::<Vec<_>>(); + plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + encode_success(id, ResponseResult::PluginList { plugins }) + } + + pub(super) fn handle_plugin_unlink( + &mut self, + id: String, + params: PluginUnlinkParams, + ) -> String { + let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { + return invalid_plugin_id(id); + }; + let previous = self.state.installed_plugins.remove(&plugin_id); + let removed = previous.is_some(); + let previous_panes = if removed { + Some(self.state.plugin_panes.clone()) + } else { + None + }; + if removed { + // Drop plugin_panes records for this plugin (panes keep running). + self.state + .plugin_panes + .retain(|_, record| record.plugin_id != plugin_id); + if let Err(err) = self.save_plugin_registry() { + if let Some(previous) = previous { + self.state + .installed_plugins + .insert(plugin_id.clone(), previous); + } + if let Some(previous_panes) = previous_panes { + self.state.plugin_panes = previous_panes; + } + return encode_error(id, "plugin_registry_save_failed", err.to_string()); + } + } + encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed }) + } + + pub(super) fn handle_plugin_enable( + &mut self, + id: String, + params: PluginSetEnabledParams, + ) -> String { + self.set_plugin_enabled(id, params.plugin_id, true) + } + + pub(super) fn handle_plugin_disable( + &mut self, + id: String, + params: PluginSetEnabledParams, + ) -> String { + self.set_plugin_enabled(id, params.plugin_id, false) + } + + pub(super) fn handle_plugin_action_list( + &mut self, + id: String, + params: PluginActionListParams, + ) -> String { + let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { + Ok(plugin_id) => plugin_id, + Err(response) => return response, + }; + let mut actions = manifest_actions(&self.state.installed_plugins) + .filter(|action| { + plugin_id + .as_deref() + .is_none_or(|plugin_id| action.plugin_id == plugin_id) + }) + .collect::<Vec<_>>(); + actions.sort_by_key(|action| action.qualified_id()); + encode_success(id, ResponseResult::PluginActionList { actions }) + } + + pub(super) fn handle_plugin_action_invoke( + &mut self, + id: String, + params: PluginActionInvokeParams, + ) -> String { + let (plugin, action) = + match self.find_plugin_action(params.plugin_id.as_deref(), ¶ms.action_id) { + Ok(pair) => pair, + Err((code, message)) => return encode_error(id, code, message), + }; + if !plugin.enabled { + return encode_error( + id, + "plugin_disabled", + format!("plugin {} is disabled", plugin.plugin_id), + ); + } + if let Err((code, message)) = ensure_platform_supported( + effective_platforms(&action.platforms, &plugin.platforms), + &format!("action '{}'", action.qualified_id()), + ) { + return encode_error(id, code, message); + } + let context = self.merge_plugin_context(params.context, &id); + let log = match self.start_plugin_command( + &plugin, + Some(action.action_id.clone()), + None, + action.command.clone(), + &context, + None, + ) { + Ok(log) => log, + Err((code, message)) => return encode_error(id, code, message), + }; + encode_success( + id, + ResponseResult::PluginActionInvoked { + action, + context, + log, + }, + ) + } + + pub(crate) fn invoke_plugin_action_from_keybind( + &mut self, + action_id: String, + ) -> Result<(), String> { + let (plugin, action) = self + .find_plugin_action(None, &action_id) + .map_err(|(_, message)| message)?; + if !plugin.enabled { + return Err(format!("plugin {} is disabled", plugin.plugin_id)); + } + ensure_platform_supported( + effective_platforms(&action.platforms, &plugin.platforms), + &action.qualified_id(), + ) + .map_err(|(_, message)| message)?; + let mut context = self.current_plugin_context("keybinding"); + context.invocation_source = Some("keybinding".to_string()); + self.start_plugin_command( + &plugin, + Some(action.action_id), + None, + action.command, + &context, + None, + ) + .map(|_| ()) + .map_err(|(_, message)| message) + } + + pub(crate) fn invoke_plugin_link_handler_for_url( + &mut self, + url: &str, + pane_id: crate::layout::PaneId, + ) -> Result<bool, String> { + let Some((plugin, handler)) = self.find_plugin_link_handler(url) else { + return Ok(false); + }; + if ensure_platform_supported( + &effective_platforms(&handler.platforms, &plugin.platforms).clone(), + &handler.id, + ) + .is_err() + { + return Ok(false); + } + let action = plugin + .actions + .iter() + .find(|action| action.id == handler.action) + .cloned() + .ok_or_else(|| { + format!( + "plugin {} link handler {} references missing action {}", + plugin.plugin_id, handler.id, handler.action + ) + })?; + ensure_platform_supported( + &effective_platforms(&action.platforms, &plugin.platforms).clone(), + &action.id, + ) + .map_err(|(_, message)| message)?; + let Some(ws_idx) = self.state.active else { + return Ok(false); + }; + let mut context = self.plugin_context_for_pane(ws_idx, pane_id, "link_click"); + context.invocation_source = Some("link_click".to_string()); + context.clicked_url = Some(url.to_string()); + context.link_handler_id = Some(handler.id); + self.start_plugin_command( + &plugin, + Some(action.id), + None, + action.command, + &context, + None, + ) + .map(|_| true) + .map_err(|(_, message)| message) + } + + pub(super) fn handle_plugin_log_list( + &mut self, + id: String, + params: PluginLogListParams, + ) -> String { + let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { + Ok(plugin_id) => plugin_id, + Err(response) => return response, + }; + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let mut logs = self + .state + .plugin_command_logs + .iter() + .filter(|log| { + plugin_id + .as_deref() + .is_none_or(|plugin_id| log.plugin_id == plugin_id) + }) + .rev() + .take(limit) + .cloned() + .collect::<Vec<_>>(); + logs.reverse(); + encode_success(id, ResponseResult::PluginLogList { logs }) + } + + pub(super) fn handle_plugin_pane_open( + &mut self, + id: String, + params: PluginPaneOpenParams, + ) -> String { + let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { + return invalid_plugin_id(id); + }; + let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { + return encode_error(id, "plugin_not_found", "plugin not found"); + }; + if !plugin_manifest_available(&plugin) { + return encode_error( + id, + "plugin_manifest_unavailable", + format!("plugin {plugin_id} manifest is unavailable"), + ); + } + if !plugin.enabled { + return encode_error( + id, + "plugin_disabled", + format!("plugin {plugin_id} is disabled"), + ); + } + let Some(entrypoint) = normalize_action_id(¶ms.entrypoint) else { + return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id"); + }; + let Some(pane) = plugin + .panes + .iter() + .find(|pane| pane.id == entrypoint) + .cloned() + else { + return encode_error( + id, + "plugin_pane_not_found", + format!("plugin pane entrypoint '{entrypoint}' not found"), + ); + }; + if let Err((code, message)) = ensure_platform_supported( + effective_platforms(&pane.platforms, &plugin.platforms), + "plugin pane", + ) { + return encode_error(id, code, message); + } + let placement = params.placement.unwrap_or(pane.placement); + match placement { + PluginPanePlacement::Overlay => { + if params.workspace_id.is_some() + || params.target_pane_id.is_some() + || params.direction.is_some() + { + return encode_error( + id, + "invalid_params", + "overlay plugin panes target the active pane", + ); + } + } + PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { + if params.workspace_id.is_some() { + return encode_error( + id, + "invalid_params", + "split and zoomed plugin panes target an existing pane; use target_pane_id", + ); + } + } + PluginPanePlacement::Tab => { + if params.target_pane_id.is_some() || params.direction.is_some() { + return encode_error( + id, + "invalid_params", + "tab plugin panes support workspace_id but not target_pane_id or direction", + ); + } + } + } + + match placement { + PluginPanePlacement::Overlay => { + self.open_plugin_overlay_pane(id, params, &plugin, pane) + } + PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { + self.open_plugin_split_pane(id, params, &plugin, pane, placement) + } + PluginPanePlacement::Tab => self.open_plugin_tab(id, params, &plugin, pane), + } + } + + pub(super) fn handle_plugin_pane_focus( + &mut self, + id: String, + params: PluginPaneFocusParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + }; + if !self.state.plugin_panes.contains_key(&pane_id) { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + } + self.state.focus_pane_in_workspace(ws_idx, pane_id); + self.state.mode = crate::app::Mode::Terminal; + let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + }; + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + }; + encode_success( + id, + ResponseResult::PluginPaneFocused { + plugin_pane: PluginPaneInfo { + plugin_id: record.plugin_id, + entrypoint: record.entrypoint, + pane, + }, + }, + ) + } + + pub(super) fn handle_plugin_pane_close( + &mut self, + id: String, + params: PluginPaneCloseParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + }; + if !self.state.plugin_panes.contains_key(&pane_id) { + return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); + } + let pane_id = params.pane_id; + if let Err(response) = self.close_pane( + id.clone(), + &crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }, + ) { + return response; + } + encode_success(id, ResponseResult::PluginPaneClosed { pane_id }) + } + + fn find_plugin_action( + &self, + plugin_id: Option<&str>, + action_id: &str, + ) -> Result<(crate::api::schema::InstalledPluginInfo, PluginActionInfo), (&'static str, String)> + { + if let Some(plugin_id) = plugin_id { + let plugin_id = normalize_plugin_id(plugin_id) + .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; + let action_id = normalize_action_id(action_id) + .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; + let plugin = self + .state + .installed_plugins + .get(&plugin_id) + .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))? + .clone(); + if !plugin_manifest_available(&plugin) { + return Err(( + "plugin_manifest_unavailable", + format!("plugin {plugin_id} manifest is unavailable"), + )); + } + let action_info = plugin + .actions + .iter() + .find(|a| a.id == action_id) + .map(|a| manifest_action_info(&plugin_id, a)) + .ok_or_else(|| { + ( + "plugin_action_not_found", + "plugin action not found".to_string(), + ) + })?; + return Ok((plugin, action_info)); + } + + let action_id = action_id.trim(); + let matches = manifest_actions(&self.state.installed_plugins) + .filter(|action| action.action_id == action_id || action.qualified_id() == action_id) + .collect::<Vec<_>>(); + match matches.as_slice() { + [action] => { + let plugin = self + .state + .installed_plugins + .get(&action.plugin_id) + .cloned() + .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?; + Ok((plugin, action.clone())) + } + [] => Err(( + "plugin_action_not_found", + "plugin action not found".to_string(), + )), + _ => Err(( + "ambiguous_plugin_action", + "plugin action id matches more than one action; include plugin_id".to_string(), + )), + } + } + + fn find_plugin_link_handler( + &self, + url: &str, + ) -> Option<(InstalledPluginInfo, PluginManifestLinkHandler)> { + let mut plugins = self + .state + .installed_plugins + .values() + .filter(|plugin| plugin.enabled && plugin_manifest_available(plugin)) + .cloned() + .collect::<Vec<_>>(); + plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + for plugin in plugins { + for handler in &plugin.link_handlers { + if ensure_platform_supported( + &effective_platforms(&handler.platforms, &plugin.platforms).clone(), + &handler.id, + ) + .is_err() + { + continue; + } + let Some(action) = plugin + .actions + .iter() + .find(|action| action.id == handler.action) + else { + continue; + }; + if ensure_platform_supported( + &effective_platforms(&action.platforms, &plugin.platforms).clone(), + &action.id, + ) + .is_err() + { + continue; + } + let Ok(regex) = regex::Regex::new(&handler.pattern) else { + continue; + }; + if regex.is_match(url) { + return Some((plugin.clone(), handler.clone())); + } + } + } + None + } + + fn set_plugin_enabled(&mut self, id: String, plugin_id: String, enabled: bool) -> String { + let Some(plugin_id) = normalize_plugin_id(&plugin_id) else { + return invalid_plugin_id(id); + }; + let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) else { + return encode_error(id, "plugin_not_found", "plugin not found"); + }; + let previous_enabled = plugin.enabled; + plugin.enabled = enabled; + if let Err(err) = self.save_plugin_registry() { + if let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) { + plugin.enabled = previous_enabled; + } + return encode_error(id, "plugin_registry_save_failed", err.to_string()); + } + let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { + return encode_error(id, "plugin_not_found", "plugin not found"); + }; + if enabled { + encode_success(id, ResponseResult::PluginEnabled { plugin }) + } else { + encode_success(id, ResponseResult::PluginDisabled { plugin }) + } + } + + pub(crate) fn save_plugin_registry(&self) -> std::io::Result<()> { + if self.no_session { + return Ok(()); + } + let plugins = self + .state + .installed_plugins + .values() + .cloned() + .collect::<Vec<_>>(); + crate::persist::plugin_registry::save(&plugins) + } +} + +fn invalid_plugin_id(id: String) -> String { + encode_error( + id, + "invalid_plugin_id", + "plugin id must be non-empty, <= 120 characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen", + ) +} + +/// Normalize an optional plugin id filter; `Err` carries the encoded +/// `invalid_plugin_id` error response. +fn normalize_optional_plugin_id( + id: &str, + plugin_id: Option<String>, +) -> Result<Option<String>, String> { + match plugin_id { + Some(plugin_id) => match normalize_plugin_id(&plugin_id) { + Some(plugin_id) => Ok(Some(plugin_id)), + None => Err(invalid_plugin_id(id.to_string())), + }, + None => Ok(None), + } +} + +fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool { + !plugin.warnings.iter().any(|warning| { + warning.starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX) + }) +} + +fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> PluginActionInfo { + PluginActionInfo { + plugin_id: plugin_id.to_string(), + action_id: action.id.clone(), + title: action.title.clone(), + description: action.description.clone(), + contexts: action.contexts.clone(), + command: action.command.clone(), + platforms: action.platforms.clone(), + } +} + +fn manifest_actions( + plugins: &crate::app::state::InstalledPluginRegistry, +) -> impl Iterator<Item = PluginActionInfo> + '_ { + plugins + .values() + .filter(|plugin| plugin_manifest_available(plugin)) + .flat_map(|plugin| { + plugin + .actions + .iter() + .map(|action| manifest_action_info(&plugin.plugin_id, action)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{ + Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, + }; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + fn response_result(response: &str) -> ResponseResult { + serde_json::from_str::<SuccessResponse>(response) + .expect("success response") + .result + } + + fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn write_manifest(root: &std::path::Path) -> std::path::PathBuf { + std::fs::create_dir_all(root).unwrap(); + let manifest = root.join("herdr-plugin.toml"); + std::fs::write( + &manifest, + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +description = "Prepare new worktrees" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["bun", "install"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] + +[[panes]] +id = "board" +title = "Worktree board" +command = ["bun", "run", "board.ts"] + +[[link_handlers]] +id = "github-pr" +title = "Open GitHub PR" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "bootstrap" +"#, + ) + .unwrap(); + manifest + } + + fn write_manifest_content(root: &std::path::Path, content: &str) -> std::path::PathBuf { + std::fs::create_dir_all(root).unwrap(); + let manifest = root.join("herdr-plugin.toml"); + std::fs::write(&manifest, content).unwrap(); + manifest + } + + fn link_manifest(app: &mut App, root: &std::path::Path) { + let result = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + assert!( + result.contains("plugin_linked"), + "expected plugin_linked: {result}" + ); + } + + #[test] + fn plugin_link_lists_and_unlinks_manifest() { + let mut app = test_app(); + let root = unique_temp_path("plugin-link"); + write_manifest(&root); + + let link = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + let ResponseResult::PluginLinked { plugin } = response_result(&link) else { + panic!("expected plugin linked response: {link}"); + }; + assert_eq!(plugin.plugin_id, "example.worktree-bootstrap"); + assert_eq!(plugin.name, "Worktree Bootstrap"); + assert_eq!(plugin.version, "0.1.0"); + assert_eq!(plugin.plugin_root, root.display().to_string()); + assert!(plugin.enabled); + assert_eq!(plugin.build.len(), 1); + assert_eq!(plugin.build[0].command, ["bun", "install"]); + assert_eq!(plugin.actions.len(), 1); + assert_eq!(plugin.actions[0].id, "bootstrap"); + assert_eq!(plugin.actions[0].command, ["bun", "run", "bootstrap.ts"]); + assert_eq!(plugin.events.len(), 1); + assert_eq!(plugin.events[0].on, "worktree.created"); + assert_eq!(plugin.panes.len(), 1); + assert_eq!(plugin.panes[0].id, "board"); + assert_eq!(plugin.panes[0].placement, PluginPanePlacement::Overlay); + assert_eq!(plugin.link_handlers.len(), 1); + assert_eq!(plugin.link_handlers[0].id, "github-pr"); + assert_eq!(plugin.link_handlers[0].action, "bootstrap"); + + let list = app.handle_api_request(Request { + id: "list".into(), + method: Method::PluginList(PluginListParams { plugin_id: None }), + }); + let ResponseResult::PluginList { plugins } = response_result(&list) else { + panic!("expected plugin list response: {list}"); + }; + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].plugin_id, "example.worktree-bootstrap"); + + let unlink = app.handle_api_request(Request { + id: "unlink".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }); + assert!(matches!( + response_result(&unlink), + ResponseResult::PluginUnlinked { + plugin_id, + removed: true + } if plugin_id == "example.worktree-bootstrap" + )); + + let list = app.handle_api_request(Request { + id: "list-empty".into(), + method: Method::PluginList(PluginListParams { plugin_id: None }), + }); + let ResponseResult::PluginList { plugins } = response_result(&list) else { + panic!("expected plugin list response: {list}"); + }; + assert!(plugins.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_link_rejects_invalid_github_source_path() { + let mut app = test_app(); + let root = unique_temp_path("plugin-invalid-source"); + write_manifest(&root); + + let response = app.handle_api_request(Request { + id: "link-invalid-source".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: Some(PluginSourceInfo { + kind: PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: None, + resolved_commit: Some("abc123".into()), + managed_path: Some(root.display().to_string()), + installed_unix_ms: Some(42), + }), + }), + }); + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "invalid_plugin_source"); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn link_rejects_duplicate_action_ids() { + let root = unique_temp_path("plugin-duplicate-action"); + write_manifest_content( + &root, + r#" +id = "example.duplicate" +name = "Duplicate" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "run" +title = "Run" +command = ["echo", "a"] + +[[actions]] +id = "run" +title = "Run again" +command = ["echo", "b"] +"#, + ); + + let result = load_plugin_manifest(&root.display().to_string(), true); + assert!(matches!(result, Err(("duplicate_plugin_action_id", _)))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn link_rejects_dotted_action_ids() { + let root = unique_temp_path("plugin-dotted-action"); + write_manifest_content( + &root, + r#" +id = "example.dotted-action" +name = "Dotted Action" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "build.release" +title = "Build" +command = ["echo", "build"] +"#, + ); + + let result = load_plugin_manifest(&root.display().to_string(), true); + assert!(matches!(result, Err(("invalid_plugin_action_id", _)))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn link_rejects_duplicate_pane_ids() { + let root = unique_temp_path("plugin-duplicate-pane"); + write_manifest_content( + &root, + r#" +id = "example.duplicate-pane" +name = "Duplicate Pane" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[panes]] +id = "ui" +title = "UI" +command = ["echo", "a"] + +[[panes]] +id = "ui" +title = "UI again" +command = ["echo", "b"] +"#, + ); + + let result = load_plugin_manifest(&root.display().to_string(), true); + assert!(matches!(result, Err(("duplicate_plugin_pane_id", _)))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn smoke_fixture_manifest_loads() { + let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true) + .expect("smoke fixture should load"); + assert_eq!(plugin.plugin_id, "example.smoke"); + assert_eq!(plugin.actions.len(), 1); + assert_eq!(plugin.events.len(), 1); + assert_eq!(plugin.panes.len(), 1); + assert!(plugin.warnings.is_empty()); + } + + #[test] + fn plugin_command_output_reader_caps_and_marks_truncation() { + let output = read_capped_plugin_output("abcdef".as_bytes(), 3); + + assert_eq!(output, "abc\n[herdr truncated plugin output after 3 bytes]"); + } + + #[test] + fn plugin_enable_disable_updates_registry_state() { + let mut app = test_app(); + let root = unique_temp_path("plugin-enable-disable"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let disabled = app.handle_api_request(Request { + id: "disable".into(), + method: Method::PluginDisable(PluginSetEnabledParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }); + let ResponseResult::PluginDisabled { plugin } = response_result(&disabled) else { + panic!("expected disabled response: {disabled}"); + }; + assert!(!plugin.enabled); + + let enabled = app.handle_api_request(Request { + id: "enable".into(), + method: Method::PluginEnable(PluginSetEnabledParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }); + let ResponseResult::PluginEnabled { plugin } = response_result(&enabled) else { + panic!("expected enabled response: {enabled}"); + }; + assert!(plugin.enabled); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_pane_open_requires_installed_plugin() { + let mut app = test_app(); + let response = app.handle_api_request(Request { + id: "pane-open".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.missing".into(), + entrypoint: "ui".into(), + placement: Some(PluginPanePlacement::Split), + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: false, + env: std::collections::HashMap::new(), + }), + }); + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "plugin_not_found"); + } + + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("plugin-target"); + workspace.custom_name = None; + let root_pane = workspace.tabs[0].root_pane; + let root_terminal = workspace.terminal_id(root_pane).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + app.state.terminals.get_mut(&root_terminal).unwrap().cwd = "/tmp".into(); + let target_public_pane_id = app.public_pane_id(0, root_pane).unwrap(); + + let root = unique_temp_path("plugin-pane-open"); + let capture = root.join("capture.txt"); + write_manifest_content( + &root, + &format!( + r#" +id = "example.pane" +name = "Pane Plugin" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Board" +command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_ENTRYPOINT_ID\" \"$HERDR_WORKSPACE_ID\" \"$HERDR_PANE_ID\" \"$HERDR_BIN_PATH\" \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"] +"#, + capture.display() + ), + ); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Overlay), + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::from([ + ("HERDR_PLUGIN_ID".to_string(), "spoofed-plugin".to_string()), + ( + "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), + "spoofed-entrypoint".to_string(), + ), + ( + "HERDR_PLUGIN_CONTEXT_JSON".to_string(), + "{\"spoofed\":true}".to_string(), + ), + ( + "HERDR_BIN_PATH".to_string(), + "/tmp/spoofed-herdr".to_string(), + ), + ]), + }), + }); + let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else { + panic!("expected plugin pane opened response: {open}"); + }; + assert_eq!(plugin_pane.plugin_id, "example.pane"); + assert_eq!(plugin_pane.entrypoint, "board"); + assert_eq!(plugin_pane.pane.label.as_deref(), Some("Plugin Board")); + let Some((_, opened_pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else { + panic!("opened pane id should parse"); + }; + assert!(app.state.plugin_panes.contains_key(&opened_pane_id)); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !capture.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env"); + let mut lines = text.lines(); + assert_eq!(lines.next(), Some(root.display().to_string().as_str())); + assert_eq!(lines.next(), Some("example.pane")); + assert_eq!(lines.next(), Some("board")); + assert_eq!(lines.next(), Some(plugin_pane.pane.workspace_id.as_str())); + assert_eq!(lines.next(), Some(plugin_pane.pane.pane_id.as_str())); + let bin_path = lines.next().expect("bin path"); + assert_ne!(bin_path, "/tmp/spoofed-herdr"); + assert_eq!( + bin_path, + std::env::current_exe() + .expect("current exe should resolve") + .display() + .to_string() + ); + let context: PluginInvocationContext = + serde_json::from_str(lines.next().expect("context json")).unwrap(); + assert_eq!( + context.workspace_id.as_deref(), + Some(plugin_pane.pane.workspace_id.as_str()) + ); + assert_eq!( + context.focused_pane_id.as_deref(), + Some(target_public_pane_id.as_str()) + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_injects_plugin_paths_and_protects_overrides() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-path-env")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + let root = unique_temp_path("plugin-pane-path-env"); + let capture = root.join("capture.txt"); + write_manifest_content( + &root, + &format!( + r#" +id = "example.path-env" +name = "Path Env" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Board" +command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\" > {}"] +"#, + capture.display() + ), + ); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.path-env".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Overlay), + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::from([ + ( + "HERDR_PLUGIN_ROOT".to_string(), + "/tmp/spoofed-root".to_string(), + ), + ( + "HERDR_PLUGIN_CONFIG_DIR".to_string(), + "/tmp/spoofed-config".to_string(), + ), + ( + "HERDR_PLUGIN_STATE_DIR".to_string(), + "/tmp/spoofed-state".to_string(), + ), + ]), + }), + }); + let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { + panic!("expected plugin pane opened response: {open}"); + }; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !capture.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env"); + let mut lines = text.lines(); + assert_eq!(lines.next(), Some(root.display().to_string().as_str())); + assert_eq!( + lines.next(), + Some( + crate::config::config_dir() + .join("plugins") + .join(crate::api::schema::plugin_managed_path_component( + "example.path-env" + )) + .display() + .to_string() + .as_str() + ) + ); + assert_eq!( + lines.next(), + Some( + crate::config::state_dir() + .join("plugins") + .join(crate::api::schema::plugin_managed_path_component( + "example.path-env" + )) + .display() + .to_string() + .as_str() + ) + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_tab_emits_tab_created_before_pane_created() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-tab")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + + let root = unique_temp_path("plugin-pane-tab-events"); + write_manifest_content( + &root, + r#" +id = "example.tab" +name = "Tab Plugin" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Board" +placement = "tab" +command = ["sh", "-c", "sleep 1"] +"#, + ); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open-tab".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.tab".into(), + entrypoint: "board".into(), + placement: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { + panic!("expected plugin pane opened response: {open}"); + }; + + let events = event_hub + .events_after(0) + .into_iter() + .map(|(_, event)| event.event) + .collect::<Vec<_>>(); + let tab_created = events + .iter() + .position(|event| *event == crate::api::schema::EventKind::TabCreated) + .expect("tab.created should be emitted"); + let pane_created = events + .iter() + .position(|event| *event == crate::api::schema::EventKind::PaneCreated) + .expect("pane.created should be emitted"); + assert!(tab_created < pane_created); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn manifest_action_list_and_invoke_with_context() { + let mut app = test_app(); + let root = unique_temp_path("plugin-action-list"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let list = app.handle_api_request(Request { + id: "list".into(), + method: Method::PluginActionList(PluginActionListParams { plugin_id: None }), + }); + let ResponseResult::PluginActionList { actions } = response_result(&list) else { + panic!("expected plugin action list: {list}"); + }; + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].qualified_id(), + "example.worktree-bootstrap.bootstrap" + ); + assert_eq!(actions[0].command, ["bun", "run", "bootstrap.ts"]); + + let invoke = app.handle_api_request(Request { + id: "invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.worktree-bootstrap".into()), + action_id: "bootstrap".into(), + context: Some(PluginInvocationContext { + workspace_id: Some("1".into()), + workspace_label: None, + workspace_cwd: None, + worktree: None, + tab_id: None, + tab_label: None, + focused_pane_id: None, + focused_pane_cwd: None, + focused_pane_agent: None, + focused_pane_status: None, + selected_text: None, + invocation_source: Some("test".into()), + correlation_id: Some("external-correlation".into()), + clicked_url: None, + link_handler_id: None, + }), + }), + }); + let ResponseResult::PluginActionInvoked { + action, + context, + log, + } = response_result(&invoke) + else { + panic!("expected plugin action invocation: {invoke}"); + }; + assert_eq!( + action.qualified_id(), + "example.worktree-bootstrap.bootstrap" + ); + assert_eq!(action.command, ["bun", "run", "bootstrap.ts"]); + assert_eq!(log.plugin_id, "example.worktree-bootstrap"); + assert_eq!(log.action_id.as_deref(), Some("bootstrap")); + assert_eq!(context.workspace_id.as_deref(), Some("1")); + assert_eq!(context.invocation_source.as_deref(), Some("test")); + assert_eq!( + context.correlation_id.as_deref(), + Some("external-correlation") + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stale_registry_entries_are_visible_but_not_runnable() { + let mut app = test_app(); + let root = unique_temp_path("plugin-stale-registry"); + write_manifest(&root); + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + let _ = std::fs::remove_dir_all(&root); + let reloaded = + crate::persist::plugin_registry::reload_manifests(vec![plugin], |path, enabled| { + load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }); + assert_eq!(reloaded.len(), 1); + assert!(reloaded[0].warnings.iter().any(|warning| warning + .starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX))); + app.state + .installed_plugins + .insert(reloaded[0].plugin_id.clone(), reloaded[0].clone()); + + let list = app.handle_api_request(Request { + id: "plugin-list".into(), + method: Method::PluginList(PluginListParams { plugin_id: None }), + }); + let ResponseResult::PluginList { plugins } = response_result(&list) else { + panic!("expected plugin list: {list}"); + }; + assert_eq!(plugins.len(), 1); + + let actions = app.handle_api_request(Request { + id: "action-list".into(), + method: Method::PluginActionList(PluginActionListParams { plugin_id: None }), + }); + let ResponseResult::PluginActionList { actions } = response_result(&actions) else { + panic!("expected action list: {actions}"); + }; + assert!(actions.is_empty()); + + let invoke = app.handle_api_request(Request { + id: "invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.worktree-bootstrap".into()), + action_id: "bootstrap".into(), + context: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); + assert_eq!(value["error"]["code"], "plugin_manifest_unavailable"); + + let pane = app.handle_api_request(Request { + id: "pane-open".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + let value: serde_json::Value = serde_json::from_str(&pane).unwrap(); + assert_eq!(value["error"]["code"], "plugin_manifest_unavailable"); + } + + #[cfg(unix)] + #[test] + fn manifest_action_invoke_runs_command_and_captures_log() { + let mut app = test_app(); + let root = unique_temp_path("plugin-action-runner"); + write_manifest_content( + &root, + r#" +id = "example.runner" +name = "Runner" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[actions]] +id = "run" +title = "Run" +command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_ACTION_ID\""] +"#, + ); + link_manifest(&mut app, &root); + + let invoke = app.handle_api_request(Request { + id: "invoke-runner".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.runner".into()), + action_id: "run".into(), + context: None, + }), + }); + let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else { + panic!("expected plugin action invocation: {invoke}"); + }; + assert_eq!(log.status, PluginCommandStatus::Running); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + app.drain_all_internal_events(); + if app.state.plugin_command_logs.iter().any(|entry| { + entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running + }) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let logs = app.handle_api_request(Request { + id: "logs".into(), + method: Method::PluginLogList(PluginLogListParams { + plugin_id: Some("example.runner".into()), + limit: Some(10), + }), + }); + let ResponseResult::PluginLogList { logs } = response_result(&logs) else { + panic!("expected plugin logs: {logs}"); + }; + let finished = logs + .iter() + .find(|entry| entry.log_id == log.log_id) + .expect("log should exist"); + assert_eq!(finished.status, PluginCommandStatus::Succeeded); + assert_eq!(finished.stdout.as_deref(), Some("run")); + assert_eq!(finished.exit_code, Some(0)); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn manifest_action_invoke_injects_plugin_paths() { + let mut app = test_app(); + let root = unique_temp_path("plugin-action-path-env"); + write_manifest_content( + &root, + r#" +id = "example.action-paths" +name = "Action Paths" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[actions]] +id = "run" +title = "Run" +command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\""] +"#, + ); + link_manifest(&mut app, &root); + + let invoke = app.handle_api_request(Request { + id: "invoke-runner".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.action-paths".into()), + action_id: "run".into(), + context: None, + }), + }); + let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else { + panic!("expected plugin action invocation: {invoke}"); + }; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + app.drain_all_internal_events(); + if app.state.plugin_command_logs.iter().any(|entry| { + entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running + }) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let logs = app.handle_api_request(Request { + id: "logs".into(), + method: Method::PluginLogList(PluginLogListParams { + plugin_id: Some("example.action-paths".into()), + limit: Some(10), + }), + }); + let ResponseResult::PluginLogList { logs } = response_result(&logs) else { + panic!("expected plugin logs: {logs}"); + }; + let finished = logs + .iter() + .find(|entry| entry.log_id == log.log_id) + .expect("log should exist"); + assert_eq!(finished.status, PluginCommandStatus::Succeeded); + let mut lines = finished.stdout.as_deref().unwrap_or_default().lines(); + assert_eq!(lines.next(), Some(root.display().to_string().as_str())); + assert_eq!( + lines.next(), + Some( + crate::config::config_dir() + .join("plugins") + .join(crate::api::schema::plugin_managed_path_component( + "example.action-paths" + )) + .display() + .to_string() + .as_str() + ) + ); + assert_eq!( + lines.next(), + Some( + crate::config::state_dir() + .join("plugins") + .join(crate::api::schema::plugin_managed_path_component( + "example.action-paths" + )) + .display() + .to_string() + .as_str() + ) + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn current_plugin_context_includes_selected_text_for_focused_pane() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("plugin-selection"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), + ); + app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None)); + + let context = app.current_plugin_context("selection-test"); + + assert_eq!(context.selected_text.as_deref(), Some("hello")); + } + + #[cfg(unix)] + #[test] + fn event_hooks_use_event_target_context() { + let mut app = test_app(); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("active"), + crate::workspace::Workspace::test_new("event-target"), + ]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let active_workspace_id = app.public_workspace_id(0); + let target_workspace = app.workspace_info(1); + + let root = unique_temp_path("plugin-event-context"); + let capture = root.join("context.json"); + write_manifest_content( + &root, + &format!( + r#" +id = "example.event-context" +name = "Event Context" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[events]] +on = "worktree.created" +command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"] +"#, + capture.display() + ), + ); + link_manifest(&mut app, &root); + + app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorktreeCreated, + data: crate::api::schema::EventData::WorktreeCreated { + workspace: target_workspace.clone(), + worktree: crate::api::schema::WorktreeInfo { + path: "/tmp/repo".into(), + branch: Some("feature".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some(target_workspace.workspace_id.clone()), + label: "feature".into(), + }, + }, + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !capture.exists() && std::time::Instant::now() < deadline { + app.drain_all_internal_events(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let context: PluginInvocationContext = + serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap(); + assert_eq!( + context.workspace_id.as_deref(), + Some(target_workspace.workspace_id.as_str()) + ); + assert_ne!( + context.workspace_id.as_deref(), + Some(active_workspace_id.as_str()) + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_command_limit_rejects_and_logs() { + let mut app = test_app(); + let root = unique_temp_path("plugin-command-limit"); + write_manifest(&root); + link_manifest(&mut app, &root); + app.state.plugin_commands_in_flight = MAX_PLUGIN_COMMANDS_IN_FLIGHT; + + let invoke = app.handle_api_request(Request { + id: "invoke-limit".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.worktree-bootstrap".into()), + action_id: "bootstrap".into(), + context: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); + assert_eq!(value["error"]["code"], "plugin_command_limit_reached"); + let log = app + .state + .plugin_command_logs + .last() + .expect("rejected command should be logged"); + assert_eq!(log.status, PluginCommandStatus::Failed); + assert!(log + .error + .as_deref() + .is_some_and(|error| error.contains("maximum concurrent plugin commands"))); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn closed_event_context_uses_closed_target_ids() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("closed-events")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let active_pane_id = app.state.workspaces[0].tabs[0].root_pane; + let active_public_pane_id = app.public_pane_id(0, active_pane_id).unwrap(); + let workspace_id = app.public_workspace_id(0); + let closed_tab_id = format!("{workspace_id}:t99"); + let closed_pane_id = format!("{workspace_id}:p99"); + + let tab_context = app.plugin_context_for_event( + &crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabClosed, + data: crate::api::schema::EventData::TabClosed { + tab_id: closed_tab_id.clone(), + workspace_id: workspace_id.clone(), + }, + }, + "tab.closed", + ); + assert_eq!( + tab_context.workspace_id.as_deref(), + Some(workspace_id.as_str()) + ); + assert_eq!(tab_context.tab_id.as_deref(), Some(closed_tab_id.as_str())); + assert_eq!(tab_context.focused_pane_id, None); + + let pane_context = app.plugin_context_for_event( + &crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneClosed, + data: crate::api::schema::EventData::PaneClosed { + pane_id: closed_pane_id.clone(), + workspace_id: workspace_id.clone(), + }, + }, + "pane.closed", + ); + assert_eq!( + pane_context.workspace_id.as_deref(), + Some(workspace_id.as_str()) + ); + assert_eq!( + pane_context.focused_pane_id.as_deref(), + Some(closed_pane_id.as_str()) + ); + assert_ne!( + pane_context.focused_pane_id.as_deref(), + Some(active_public_pane_id.as_str()) + ); + } + + #[cfg(unix)] + #[test] + fn plugin_link_handler_invokes_action_with_clicked_url_context() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-handler")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let root = unique_temp_path("plugin-link-handler"); + write_manifest_content( + &root, + r#" +id = "example.links" +name = "Links" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[actions]] +id = "open" +title = "Open link" +command = ["sh", "-c", "printf '%s|%s' \"$HERDR_PLUGIN_LINK_HANDLER_ID\" \"$HERDR_PLUGIN_CLICKED_URL\""] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "open" +"#, + ); + link_manifest(&mut app, &root); + + let handled = app + .invoke_plugin_link_handler_for_url( + "https://github.com/ogulcancelik/herdr/issues/398", + pane_id, + ) + .expect("link handler should invoke"); + assert!(handled); + let log = app + .state + .plugin_command_logs + .last() + .expect("plugin command log should be recorded") + .clone(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + app.drain_all_internal_events(); + if app.state.plugin_command_logs.iter().any(|entry| { + entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running + }) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let finished = app + .state + .plugin_command_logs + .iter() + .find(|entry| entry.log_id == log.log_id) + .expect("log should exist"); + assert_eq!(finished.status, PluginCommandStatus::Succeeded); + assert_eq!(finished.action_id.as_deref(), Some("open")); + assert_eq!( + finished.stdout.as_deref(), + Some("github-issue|https://github.com/ogulcancelik/herdr/issues/398") + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_link_handlers_keep_manifest_order_for_overlapping_patterns() { + let mut app = test_app(); + let root = unique_temp_path("plugin-link-handler-order"); + write_manifest_content( + &root, + r#" +id = "example.link-order" +name = "Link Order" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "specific" +title = "Specific" +command = ["true"] + +[[actions]] +id = "generic" +title = "Generic" +command = ["true"] + +[[link_handlers]] +id = "z-specific" +title = "Specific GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/issues/[0-9]+$" +action = "specific" + +[[link_handlers]] +id = "a-generic" +title = "Generic GitHub" +pattern = "^https://github\\.com/" +action = "generic" +"#, + ); + link_manifest(&mut app, &root); + + let (_plugin, handler) = app + .find_plugin_link_handler("https://github.com/ogulcancelik/herdr/issues/398") + .expect("handler should match"); + assert_eq!(handler.id, "z-specific"); + assert_eq!(handler.action, "specific"); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_link_rejects_invalid_link_handler_pattern() { + let mut app = test_app(); + let root = unique_temp_path("plugin-bad-link-handler-pattern"); + write_manifest_content( + &root, + r#" +id = "example.bad-links" +name = "Bad Links" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "open" +title = "Open link" +command = ["true"] + +[[link_handlers]] +id = "bad" +title = "Bad" +pattern = "[" +action = "open" +"#, + ); + + let response = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!( + value["error"]["code"], + "invalid_plugin_link_handler_pattern" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_link_rejects_link_handler_unknown_action() { + let mut app = test_app(); + let root = unique_temp_path("plugin-bad-link-handler-action"); + write_manifest_content( + &root, + r#" +id = "example.bad-link-action" +name = "Bad Link Action" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "open" +title = "Open link" +command = ["true"] + +[[link_handlers]] +id = "github" +title = "GitHub" +pattern = "^https://github\\.com/" +action = "missing" +"#, + ); + + let response = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "invalid_plugin_link_handler_action"); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn manifest_action_invoke_builds_default_workspace_context() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + app.state.workspaces[0].identity_cwd = "/tmp/issue".into(); + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.workspaces[0].custom_name = Some("Plugin Work".into()); + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let pane_public = app.public_pane_id(0, pane_id).unwrap(); + let tab_public = app.public_tab_id(0, 0).unwrap(); + let workspace_public = app.public_workspace_id(0); + let _ = app.handle_pane_report_agent( + "report".into(), + crate::api::schema::PaneReportAgentParams { + pane_id: pane_public.clone(), + source: "test".into(), + agent: "codex".into(), + state: crate::api::schema::PaneAgentState::Working, + message: None, + custom_status: None, + seq: None, + agent_session_id: None, + agent_session_path: None, + }, + ); + + let root = unique_temp_path("plugin-action-context"); + // write a manifest with a "show" action in pane context + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("herdr-plugin.toml"), + r#" +id = "example.context" +name = "Context" +version = "0.1.0" + +[[actions]] +id = "show" +title = "Show Context" +contexts = ["pane"] +command = ["show-ctx"] +"#, + ) + .unwrap(); + link_manifest(&mut app, &root); + + let invoke = app.handle_api_request(Request { + id: "invoke-context".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.context".into()), + action_id: "show".into(), + context: None, + }), + }); + + let ResponseResult::PluginActionInvoked { context, .. } = response_result(&invoke) else { + panic!("expected plugin action invocation: {invoke}"); + }; + assert_eq!( + context.workspace_id.as_deref(), + Some(workspace_public.as_str()) + ); + assert_eq!(context.workspace_label.as_deref(), Some("Plugin Work")); + assert_eq!(context.workspace_cwd.as_deref(), Some("/tmp/issue")); + assert_eq!(context.tab_id.as_deref(), Some(tab_public.as_str())); + assert_eq!(context.tab_label.as_deref(), Some("1")); + assert_eq!( + context.focused_pane_id.as_deref(), + Some(pane_public.as_str()) + ); + assert_eq!(context.focused_pane_cwd.as_deref(), Some("/tmp/issue")); + assert_eq!(context.focused_pane_agent.as_deref(), Some("codex")); + assert_eq!( + context.focused_pane_status, + Some(crate::api::schema::AgentStatus::Working) + ); + assert_eq!(context.invocation_source.as_deref(), Some("api")); + assert_eq!(context.correlation_id.as_deref(), Some("invoke-context")); + let worktree = context.worktree.as_ref().unwrap(); + assert_eq!(worktree.repo_key, "repo-key"); + assert_eq!(worktree.repo_name, "herdr"); + assert_eq!(worktree.repo_root, "/repo/herdr"); + assert_eq!(worktree.checkout_path, "/repo/herdr-issue"); + assert!(worktree.is_linked_worktree); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn manifest_action_invoke_returns_plugin_disabled_when_disabled() { + let mut app = test_app(); + let root = unique_temp_path("plugin-disabled"); + write_manifest(&root); + + // link as disabled + let link_result = app.handle_api_request(Request { + id: "link-disabled".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: false, + source: None, + }), + }); + assert!( + link_result.contains("plugin_linked"), + "expected plugin_linked: {link_result}" + ); + + let invoke = app.handle_api_request(Request { + id: "invoke-disabled".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.worktree-bootstrap".into()), + action_id: "bootstrap".into(), + context: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); + assert_eq!(value["error"]["code"], "plugin_disabled"); + + let _ = std::fs::remove_dir_all(root); + } + + fn write_manifest_with_bad_event(root: &std::path::Path) -> std::path::PathBuf { + std::fs::create_dir_all(root).unwrap(); + let manifest = root.join("herdr-plugin.toml"); + std::fs::write( + &manifest, + r#" +id = "example.bad-event" +name = "Bad Event Plugin" +version = "0.1.0" + +[[events]] +on = "worktree.craeted" +command = ["sh", "-c", "echo hi"] + +[[events]] +on = "pane.output_changed" +command = ["sh", "-c", "echo too noisy"] + +[[events]] +on = "worktree.created" +command = ["sh", "-c", "echo ok"] +"#, + ) + .unwrap(); + manifest + } + + #[test] + fn link_with_unknown_event_name_succeeds_with_warning() { + let mut app = test_app(); + let root = unique_temp_path("plugin-bad-event"); + write_manifest_with_bad_event(&root); + + let link = app.handle_api_request(Request { + id: "link-bad-event".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + + let ResponseResult::PluginLinked { plugin } = response_result(&link) else { + panic!("expected plugin_linked: {link}"); + }; + assert_eq!(plugin.plugin_id, "example.bad-event"); + assert!( + plugin + .warnings + .iter() + .any(|w| w.contains("worktree.craeted")), + "expected warning for misspelled event, got: {:?}", + plugin.warnings + ); + // The correctly named event produces no extra warning + assert_eq!( + plugin + .warnings + .iter() + .filter(|w| w.contains("worktree.created")) + .count(), + 0 + ); + assert!( + plugin + .warnings + .iter() + .any(|w| w.contains("pane.output_changed")), + "expected warning for unemitted output-change hook, got: {:?}", + plugin.warnings + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn output_changed_event_hooks_do_not_run_even_if_event_is_emitted() { + let mut app = test_app(); + let root = unique_temp_path("plugin-output-hook"); + write_manifest_with_bad_event(&root); + link_manifest(&mut app, &root); + + app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneOutputChanged, + data: crate::api::schema::EventData::PaneOutputChanged { + pane_id: "w1-1".into(), + workspace_id: "w1".into(), + revision: 1, + }, + }); + + assert!( + app.state.plugin_command_logs.is_empty(), + "pane.output_changed hooks should be warning-only until hook semantics exist" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn unlink_removes_plugin_pane_records_for_that_plugin() { + let mut app = test_app(); + let root = unique_temp_path("plugin-unlink-panes"); + write_manifest(&root); + link_manifest(&mut app, &root); + + // Manually insert plugin_pane records as if pane.open was called. + let pane_a = crate::layout::PaneId::from_raw(1001u32); + let pane_b = crate::layout::PaneId::from_raw(1002u32); + let pane_other = crate::layout::PaneId::from_raw(1003u32); + app.state.plugin_panes.insert( + pane_a, + crate::app::state::PluginPaneRecord { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "main".into(), + }, + ); + app.state.plugin_panes.insert( + pane_b, + crate::app::state::PluginPaneRecord { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "side".into(), + }, + ); + app.state.plugin_panes.insert( + pane_other, + crate::app::state::PluginPaneRecord { + plugin_id: "other.plugin".into(), + entrypoint: "other".into(), + }, + ); + + let unlink = app.handle_api_request(Request { + id: "unlink-panes".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }); + assert!(matches!( + response_result(&unlink), + ResponseResult::PluginUnlinked { removed: true, .. } + )); + + // plugin_panes records for the unlinked plugin are gone + assert!(!app.state.plugin_panes.contains_key(&pane_a)); + assert!(!app.state.plugin_panes.contains_key(&pane_b)); + // other plugin's pane record survives + assert!(app.state.plugin_panes.contains_key(&pane_other)); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_pane_record_survives_pane_move() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-move")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); + app.state.plugin_panes.insert( + pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + }, + ); + + let response = app.handle_api_request(Request { + id: "move".into(), + method: Method::PaneMove(crate::api::schema::PaneMoveParams { + pane_id: public_pane_id, + destination: crate::api::schema::PaneMoveDestination::NewTab { + workspace_id: None, + label: Some("moved".into()), + }, + focus: true, + }), + }); + let ResponseResult::PaneMove { move_result } = response_result(&response) else { + panic!("expected pane move: {response}"); + }; + assert!(app.state.plugin_panes.contains_key(&pane_id)); + + let focus = app.handle_api_request(Request { + id: "focus".into(), + method: Method::PluginPaneFocus(PluginPaneFocusParams { + pane_id: move_result.pane.pane_id.clone(), + }), + }); + let ResponseResult::PluginPaneFocused { plugin_pane } = response_result(&focus) else { + panic!("expected plugin pane focus: {focus}"); + }; + assert_eq!(plugin_pane.plugin_id, "example.pane"); + assert_eq!(plugin_pane.entrypoint, "board"); + assert_eq!(plugin_pane.pane.pane_id, move_result.pane.pane_id); + } + + #[test] + fn pane_exit_removes_plugin_pane_record() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-exit")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.plugin_panes.insert( + pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: "example.pane".into(), + entrypoint: "board".into(), + }, + ); + + app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id }); + + assert!(!app.state.plugin_panes.contains_key(&pane_id)); + } + + #[test] + fn registry_round_trip_via_explicit_path() { + let root = unique_temp_path("plugin-registry-rt"); + write_manifest(&root); + + let registry_dir = unique_temp_path("registry-dir"); + std::fs::create_dir_all(®istry_dir).unwrap(); + let registry_path = registry_dir.join("plugins.json"); + + // link via load_plugin_manifest + save_to_path (simulating what the App does) + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + let plugins = vec![plugin.clone()]; + crate::persist::plugin_registry::save_to_path(®istry_path, &plugins).unwrap(); + assert!(registry_path.exists()); + + // load back and verify + let loaded = crate::persist::plugin_registry::load_from_path(®istry_path); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].plugin_id, "example.worktree-bootstrap"); + assert_eq!(loaded[0].name, "Worktree Bootstrap"); + + // reload_manifests with real manifest still on disk → fresh parse succeeds + let reloaded = + crate::persist::plugin_registry::reload_manifests(loaded, |path, enabled| { + load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }); + assert_eq!(reloaded.len(), 1); + assert!(reloaded[0].warnings.is_empty()); + assert_eq!(reloaded[0].version, "0.1.0"); + + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(registry_dir); + } + + #[test] + fn reload_manifests_keeps_entry_with_warning_when_manifest_gone() { + let root = unique_temp_path("plugin-missing-manifest"); + write_manifest(&root); + + // load_plugin_manifest resolves the absolute path via canonicalize() + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + let stored_manifest_path = plugin.manifest_path.clone(); + + // Now delete the manifest + let _ = std::fs::remove_dir_all(&root); + + // Simulate registry load + reload + let entries = vec![plugin]; + let reloaded = + crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { + load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }); + + assert_eq!(reloaded.len(), 1); + assert_eq!(reloaded[0].plugin_id, "example.worktree-bootstrap"); + assert!(!reloaded[0].warnings.is_empty(), "expected load warning"); + // The stored manifest_path is preserved so the entry is still identifiable + assert_eq!(reloaded[0].manifest_path, stored_manifest_path); + } + + // ── Platform compatibility tests ───────────────────────────────────────── + + #[test] + fn manifest_with_platforms_parses_correctly() { + let root = unique_temp_path("plugin-platforms"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("herdr-plugin.toml"), + r#" +id = "example.platforms" +name = "Platforms" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[actions]] +id = "run" +title = "Run" +command = ["./run.sh"] + +[[actions]] +id = "run-win" +title = "Run Windows" +platforms = ["windows"] +command = ["run.bat"] +"#, + ) + .unwrap(); + + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + use crate::api::schema::PluginPlatform; + assert_eq!( + plugin.platforms, + Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) + ); + // Action without own platforms inherits from plugin level + let run = plugin.actions.iter().find(|a| a.id == "run").unwrap(); + assert!(run.platforms.is_none()); + // Action with own platforms has them set + let run_win = plugin.actions.iter().find(|a| a.id == "run-win").unwrap(); + assert_eq!(run_win.platforms, Some(vec![PluginPlatform::Windows])); + // No missing-platforms warning because platforms is declared + assert!( + plugin.warnings.is_empty(), + "expected no warnings: {:?}", + plugin.warnings + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn effective_platform_resolution_inherits_from_plugin() { + use crate::api::schema::PluginPlatform; + let plugin_platforms = Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]); + let no_override: Option<Vec<PluginPlatform>> = None; + let action_override = Some(vec![PluginPlatform::Windows]); + + // No action-level platforms → inherit from plugin + assert_eq!( + effective_platforms(&no_override, &plugin_platforms), + &plugin_platforms + ); + // Action-level platforms → use action's own list + assert_eq!( + effective_platforms(&action_override, &plugin_platforms), + &action_override + ); + // Both None → None (undeclared) + assert_eq!(effective_platforms(&no_override, &no_override), &None); + } + + #[test] + fn invoke_on_unsupported_platform_returns_error() { + let mut app = test_app(); + let root = unique_temp_path("plugin-platform-reject"); + std::fs::create_dir_all(&root).unwrap(); + + // Declare only platforms that are NOT the current build target so the + // invoke is guaranteed to be rejected regardless of which OS this runs on. + let excluded_platforms = if cfg!(target_os = "linux") { + r#"platforms = ["macos", "windows"]"# + } else if cfg!(target_os = "macos") { + r#"platforms = ["linux", "windows"]"# + } else { + r#"platforms = ["linux", "macos"]"# + }; + + std::fs::write( + root.join("herdr-plugin.toml"), + format!( + r#" +id = "example.reject" +name = "Reject" +version = "0.1.0" +{excluded_platforms} + +[[actions]] +id = "act" +title = "Act" +command = ["act"] +"# + ), + ) + .unwrap(); + + let link = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + assert!(link.contains("plugin_linked"), "link failed: {link}"); + + let invoke = app.handle_api_request(Request { + id: "invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.reject".into()), + action_id: "act".into(), + context: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); + assert_eq!( + value["error"]["code"], "platform_unsupported", + "expected platform_unsupported error: {invoke}" + ); + assert!( + invoke.contains("example.reject.act"), + "error message should name the action: {invoke}" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn invoke_with_action_platform_override_uses_action_platforms() { + let mut app = test_app(); + let root = unique_temp_path("plugin-platform-action-override"); + std::fs::create_dir_all(&root).unwrap(); + + // Plugin declares all platforms; action declares only the non-current platforms. + let excluded_platforms = if cfg!(target_os = "linux") { + r#"platforms = ["macos", "windows"]"# + } else if cfg!(target_os = "macos") { + r#"platforms = ["linux", "windows"]"# + } else { + r#"platforms = ["linux", "macos"]"# + }; + + std::fs::write( + root.join("herdr-plugin.toml"), + format!( + r#" +id = "example.override" +name = "Override" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "act" +title = "Act" +{excluded_platforms} +command = ["act"] +"# + ), + ) + .unwrap(); + + let link = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + assert!(link.contains("plugin_linked"), "link failed: {link}"); + + let invoke = app.handle_api_request(Request { + id: "invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.override".into()), + action_id: "act".into(), + context: None, + }), + }); + let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); + assert_eq!( + value["error"]["code"], "platform_unsupported", + "expected platform_unsupported for action override: {invoke}" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn invoke_with_undeclared_platforms_succeeds() { + let mut app = test_app(); + let root = unique_temp_path("plugin-platform-undeclared"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("herdr-plugin.toml"), + r#" +id = "example.nodecl" +name = "No Decl" +version = "0.1.0" + +[[actions]] +id = "act" +title = "Act" +command = ["act"] +"#, + ) + .unwrap(); + + let link = app.handle_api_request(Request { + id: "link".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: None, + }), + }); + let ResponseResult::PluginLinked { plugin } = response_result(&link) else { + panic!("expected plugin_linked: {link}"); + }; + // Should get the missing-platforms warning + assert!( + plugin + .warnings + .iter() + .any(|w| w.contains("does not declare platforms")), + "expected missing-platforms warning: {:?}", + plugin.warnings + ); + + // Invoke should succeed regardless (local dev allowance) + let invoke = app.handle_api_request(Request { + id: "invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.nodecl".into()), + action_id: "act".into(), + context: None, + }), + }); + assert!( + invoke.contains("plugin_action_invoked"), + "expected success for undeclared platforms: {invoke}" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn link_with_invalid_platform_string_fails() { + let root = unique_temp_path("plugin-bad-platform"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("herdr-plugin.toml"), + r#" +id = "example.badplatform" +name = "Bad Platform" +version = "0.1.0" +platforms = ["linux", "beos"] + +[[actions]] +id = "act" +title = "Act" +command = ["act"] +"#, + ) + .unwrap(); + + let result = load_plugin_manifest(&root.display().to_string(), true); + assert!(result.is_err(), "expected parse error for unknown platform"); + let (_, msg) = result.unwrap_err(); + assert!( + msg.contains("beos") || msg.contains("platform"), + "error message should mention the bad platform: {msg}" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn registry_round_trip_preserves_platforms() { + let root = unique_temp_path("plugin-platform-rt"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("herdr-plugin.toml"), + r#" +id = "example.platform-rt" +name = "Platform RT" +version = "0.1.0" +platforms = ["linux", "macos"] + +[[actions]] +id = "act" +title = "Act" +platforms = ["windows"] +command = ["act.exe"] +"#, + ) + .unwrap(); + + let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); + use crate::api::schema::PluginPlatform; + assert_eq!( + plugin.platforms, + Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) + ); + assert_eq!( + plugin.actions[0].platforms, + Some(vec![PluginPlatform::Windows]) + ); + + let registry_dir = unique_temp_path("platform-rt-registry"); + std::fs::create_dir_all(®istry_dir).unwrap(); + let registry_path = registry_dir.join("plugins.json"); + crate::persist::plugin_registry::save_to_path(®istry_path, &[plugin]).unwrap(); + + let loaded = crate::persist::plugin_registry::load_from_path(®istry_path); + assert_eq!( + loaded[0].platforms, + Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) + ); + assert_eq!( + loaded[0].actions[0].platforms, + Some(vec![PluginPlatform::Windows]) + ); + + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(registry_dir); + } +} diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs new file mode 100644 index 00000000..22173cbf --- /dev/null +++ b/src/app/api/plugins/panes.rs @@ -0,0 +1,284 @@ +use ratatui::layout::Direction; + +use super::super::responses::{encode_error, encode_success}; +use crate::api::schema::{ + InstalledPluginInfo, PluginInvocationContext, PluginManifestPane, PluginPaneInfo, + PluginPaneOpenParams, PluginPanePlacement, ResponseResult, +}; +use crate::app::App; + +impl App { + pub(super) fn open_plugin_overlay_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let context = self.current_plugin_context("plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let (ws_idx, new_pane) = + match self.spawn_overlay_argv_command(&pane.command, cwd, extra_env, Vec::new()) { + Ok(result) => result, + Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + }; + self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + } + + pub(super) fn open_plugin_split_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + placement: PluginPanePlacement, + ) -> String { + let target_pane_id = params + .target_pane_id + .clone() + .or_else(|| self.current_public_pane_id()); + let Some(target_pane_id) = target_pane_id else { + return encode_error(id, "no_active_pane", "no active pane"); + }; + let Some((ws_idx, target_pane)) = self.parse_pane_id(&target_pane_id) else { + return encode_error( + id, + "pane_not_found", + format!("pane {target_pane_id} not found"), + ); + }; + let context = self.plugin_context_for_pane(ws_idx, target_pane, "plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let direction = match params + .direction + .unwrap_or(crate::api::schema::SplitDirection::Right) + { + crate::api::schema::SplitDirection::Right => Direction::Horizontal, + crate::api::schema::SplitDirection::Down => Direction::Vertical, + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let (rows, cols) = self.state.estimate_pane_size(); + let previous_focus = self.state.current_pane_focus_target(); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + let result = ws.split_pane_argv_command( + target_pane, + direction, + rows.max(4), + cols.max(10), + cwd, + &pane.command, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + params.focus || placement == PluginPanePlacement::Zoomed, + ); + let (tab_idx, new_pane) = match result { + Some(Ok(result)) => result, + Some(Err(err)) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + None => { + return encode_error( + id, + "pane_not_found", + format!("pane {target_pane_id} not found"), + ) + } + }; + if params.focus || placement == PluginPanePlacement::Zoomed { + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state + .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); + self.state.mode = crate::app::Mode::Terminal; + } + if placement == PluginPanePlacement::Zoomed { + if let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.zoomed = true; + } + } + self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + } + + pub(super) fn open_plugin_tab( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let ws_idx = match params.workspace_id.as_deref() { + Some(workspace_id) => match self.parse_workspace_id(workspace_id) { + Some(ws_idx) => ws_idx, + None => return encode_error(id, "workspace_not_found", "workspace not found"), + }, + None => match self.state.active { + Some(ws_idx) => ws_idx, + None => return encode_error(id, "no_active_workspace", "no active workspace"), + }, + }; + let cwd = self.plugin_pane_cwd(plugin, params.cwd); + let context = self.plugin_context_for_workspace(ws_idx, "plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let (rows, cols) = self.state.estimate_pane_size(); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command( + rows.max(4), + cols.max(10), + cwd, + &pane.command, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + ) { + Ok(result) => result, + Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + }; + let pane_id = ws.tabs[tab_idx].root_pane; + if params.focus { + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state.mode = crate::app::Mode::Terminal; + } + let new_pane = crate::workspace::NewPane { + pane_id, + terminal, + runtime, + }; + self.finish_plugin_pane_open( + id, + ws_idx, + Some(tab_idx), + new_pane, + plugin.plugin_id.clone(), + pane, + ) + } + + fn plugin_pane_launch_env( + &self, + plugin: &InstalledPluginInfo, + entrypoint: &str, + env: std::collections::HashMap<String, String>, + context: &PluginInvocationContext, + ) -> Result<Vec<(String, String)>, (String, String)> { + let mut env = super::super::env::normalize_launch_env(env)?; + let context_json = serde_json::to_string(&context) + .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?; + env.retain(|(key, _)| !plugin_pane_protected_env_key(key)); + env.extend(super::env::plugin_path_env(plugin)); + env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone())); + env.push(( + "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), + entrypoint.to_string(), + )); + env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json)); + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + Ok(env) + } + + fn finish_plugin_pane_open( + &mut self, + id: String, + ws_idx: usize, + created_tab_idx: Option<usize>, + new_pane: crate::workspace::NewPane, + plugin_id: String, + pane_manifest: PluginManifestPane, + ) -> String { + let entrypoint = pane_manifest.id.clone(); + let mut terminal = new_pane.terminal; + terminal.set_manual_label(pane_manifest.title.clone()); + let terminal_id = terminal.id.clone(); + self.terminal_runtimes + .insert(terminal_id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state.terminals.insert(terminal_id, terminal); + self.state.plugin_panes.insert( + new_pane.pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: plugin_id.clone(), + entrypoint: entrypoint.clone(), + }, + ); + if let Some(tab_idx) = created_tab_idx { + if let Some(tab) = self.tab_info(ws_idx, tab_idx) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabCreated, + data: crate::api::schema::EventData::TabCreated { tab }, + }); + } + } + self.schedule_session_save(); + let Some(pane) = self.pane_info(ws_idx, new_pane.pane_id) else { + return encode_error(id, "plugin_pane_open_failed", "plugin pane disappeared"); + }; + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneCreated, + data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, + }); + encode_success( + id, + ResponseResult::PluginPaneOpened { + plugin_pane: PluginPaneInfo { + plugin_id, + entrypoint, + pane, + }, + }, + ) + } + + fn plugin_pane_cwd( + &self, + plugin: &InstalledPluginInfo, + override_cwd: Option<String>, + ) -> std::path::PathBuf { + override_cwd + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(&plugin.plugin_root)) + } + + fn current_public_pane_id(&self) -> Option<String> { + let ws_idx = self.state.active?; + let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; + self.public_pane_id(ws_idx, pane_id) + } +} + +fn plugin_pane_protected_env_key(key: &str) -> bool { + matches!( + key, + "HERDR_PLUGIN_ID" + | "HERDR_PLUGIN_ROOT" + | "HERDR_PLUGIN_CONFIG_DIR" + | "HERDR_PLUGIN_STATE_DIR" + | "HERDR_PLUGIN_ENTRYPOINT_ID" + | "HERDR_PLUGIN_CONTEXT_JSON" + | "HERDR_BIN_PATH" + ) +} diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs new file mode 100644 index 00000000..c6955bb4 --- /dev/null +++ b/src/app/api/plugins/runtime.rs @@ -0,0 +1,271 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use super::manifest::{effective_platforms, ensure_platform_supported}; +use super::plugin_manifest_available; +use crate::api::schema::{ + InstalledPluginInfo, PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext, +}; +use crate::app::App; + +const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024; +pub(super) const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32; +const PLUGIN_COMMAND_LOG_LIMIT: usize = 200; + +impl App { + pub(super) fn start_plugin_command( + &mut self, + plugin: &InstalledPluginInfo, + action_id: Option<String>, + event: Option<String>, + command: Vec<String>, + context: &PluginInvocationContext, + event_json: Option<String>, + ) -> Result<PluginCommandLogInfo, (&'static str, String)> { + let Some(program) = command.first().cloned() else { + return Err(( + "invalid_plugin_command", + "command must not be empty".to_string(), + )); + }; + let args = command.iter().skip(1).cloned().collect::<Vec<_>>(); + let context_json = serde_json::to_string(context) + .map_err(|err| ("invalid_plugin_context", err.to_string()))?; + let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id); + self.state.next_plugin_command_log_id += 1; + let started_unix_ms = current_unix_ms(); + let mut env = super::env::plugin_path_env(plugin); + env.extend([ + ( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + ), + ("HERDR_ENV".to_string(), "1".to_string()), + ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), + ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), + ]); + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + if let Some(action_id) = action_id.as_ref() { + env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone())); + } + if let Some(event) = event.as_ref() { + env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone())); + } + if let Some(event_json) = event_json { + env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json)); + } + if let Some(workspace_id) = context.workspace_id.as_ref() { + env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone())); + } + if let Some(tab_id) = context.tab_id.as_ref() { + env.push(("HERDR_TAB_ID".to_string(), tab_id.clone())); + } + if let Some(pane_id) = context.focused_pane_id.as_ref() { + env.push(("HERDR_PANE_ID".to_string(), pane_id.clone())); + } + if let Some(clicked_url) = context.clicked_url.as_ref() { + env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone())); + } + if let Some(link_handler_id) = context.link_handler_id.as_ref() { + env.push(( + "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(), + link_handler_id.clone(), + )); + } + if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT { + let message = format!( + "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})" + ); + let log = PluginCommandLogInfo { + log_id, + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command, + status: PluginCommandStatus::Failed, + started_unix_ms, + finished_unix_ms: Some(started_unix_ms), + exit_code: None, + stdout: Some(String::new()), + stderr: Some(String::new()), + error: Some(message.clone()), + }; + self.push_plugin_command_log(log); + return Err(("plugin_command_limit_reached", message)); + } + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root); + let log = PluginCommandLogInfo { + log_id: log_id.clone(), + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command: command.clone(), + status: PluginCommandStatus::Running, + started_unix_ms, + finished_unix_ms: None, + exit_code: None, + stdout: None, + stderr: None, + error: None, + }; + self.push_plugin_command_log(log.clone()); + self.state.plugin_commands_in_flight += 1; + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let child = Command::new(&program) + .args(args) + .current_dir(plugin_root) + .envs(env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let finished = match child { + Ok(mut child) => { + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = stdout.map(|stdout| { + std::thread::spawn(move || { + read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + let stderr_reader = stderr.map(|stderr| { + std::thread::spawn(move || { + read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + match child.wait() { + Ok(status) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: status.code(), + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: None, + }, + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: Some(err.to_string()), + }, + } + } + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + }, + }; + let _ = event_tx.blocking_send(finished); + }); + Ok(log) + } + + pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) { + let event_name = event.event.dot_name(); + if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) { + return; + } + let plugins = self + .state + .installed_plugins + .values() + .filter(|plugin| { + plugin.enabled + && plugin_manifest_available(plugin) + && plugin.events.iter().any(|hook| hook.on == event_name) + }) + .cloned() + .collect::<Vec<_>>(); + if plugins.is_empty() { + return; + } + let event_json = serde_json::to_string(event).ok(); + let context = self.plugin_context_for_event(event, event_name); + for plugin in plugins { + for hook in plugin.events.clone() { + if hook.on != event_name { + continue; + } + if ensure_platform_supported( + &effective_platforms(&hook.platforms, &plugin.platforms).clone(), + event_name, + ) + .is_err() + { + continue; + } + let _ = self.start_plugin_command( + &plugin, + None, + Some(event_name.to_string()), + hook.command.clone(), + &context, + event_json.clone(), + ); + } + } + } + + fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) { + self.state.plugin_command_logs.push(log); + if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT { + let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT; + self.state.plugin_command_logs.drain(0..extra); + } + } +} + +fn current_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String { + let mut kept = Vec::with_capacity(cap.min(8192)); + let mut buf = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let remaining = cap.saturating_sub(kept.len()); + if remaining > 0 { + kept.extend_from_slice(&buf[..n.min(remaining)]); + } + if n > remaining { + truncated = true; + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + let mut output = String::from_utf8_lossy(&kept).into_owned(); + if truncated { + output.push_str(&format!( + "\n[herdr truncated plugin output after {cap} bytes]" + )); + } + output +} diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 7c7ad9d7..c88a00d1 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -52,6 +52,7 @@ impl App { cwd, focus, label, + env, } = params; let ws_idx = if let Some(workspace_id) = workspace_id { let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { @@ -74,6 +75,10 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; + let extra_env = match super::env::normalize_launch_env(env) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; let result = self .state .workspaces @@ -87,6 +92,7 @@ impl App { scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), + extra_env, ) }); match result { @@ -195,6 +201,13 @@ impl App { }; let workspace_id = self.public_workspace_id(ws_idx); let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + let pane_ids = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .map(|tab| tab.layout.pane_ids()) + .unwrap_or_default(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return tab_not_found(id, &target.tab_id); }; @@ -212,6 +225,9 @@ impl App { format!("tab {} could not be closed", target.tab_id), ); } + for pane_id in pane_ids { + self.state.plugin_panes.remove(&pane_id); + } self.state.remove_unattached_terminal_ids(terminal_ids); self.shutdown_detached_terminal_runtimes(); self.schedule_session_save(); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index e1966adf..cdfd4ec5 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -51,7 +51,11 @@ impl App { .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); self.resolve_new_terminal_cwd(follow_cwd) }); - match self.create_workspace_with_options(cwd, params.focus) { + let extra_env = match super::env::normalize_launch_env(params.env) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + match self.create_workspace_with_launch_env(cwd, params.focus, extra_env) { Ok(index) => { if let Some(label) = params.label { if let Some(workspace) = self.state.workspaces.get_mut(index) { @@ -147,12 +151,30 @@ impl App { return workspace_not_found(id, &target.workspace_id); } let workspace_id = self.public_workspace_id(index); + let workspace = self.workspace_info(index); + let pane_ids = self + .state + .workspaces + .get(index) + .map(|ws| { + ws.tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids()) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); self.state.selected = index; self.state.close_selected_workspace(); + for pane_id in pane_ids { + self.state.plugin_panes.remove(&pane_id); + } self.shutdown_detached_terminal_runtimes(); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, - data: EventData::WorkspaceClosed { workspace_id }, + data: EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace), + }, }); encode_success(id, ResponseResult::Ok {}) @@ -208,4 +230,37 @@ mod tests { assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } + + #[test] + fn api_workspace_close_event_includes_final_worktree_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = app_with_linked_worktree().state.workspaces; + let workspace_id = app.state.workspaces[0].id.clone(); + + let response = app.handle_workspace_close( + "req".into(), + WorkspaceTarget { + workspace_id: workspace_id.clone(), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + let events = event_hub.events_after(0); + assert!(events.iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorkspaceClosed { + workspace_id: closed_id, + workspace: Some(workspace), + } if closed_id == &workspace_id + && workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + ) + })); + } } diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 573771e2..648b571a 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -148,6 +148,13 @@ impl App { let worktree = self .worktree_info_for_checkout(&source, ws_idx) .expect("created worktree workspace should have worktree info"); + self.emit_event(EventEnvelope { + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: self.workspace_info(ws_idx), + worktree: worktree.clone(), + }, + }); encode_success( id, ResponseResult::WorktreeCreated { @@ -244,6 +251,15 @@ impl App { } let tab_idx = self.state.workspaces[ws_idx].active_tab; + let worktree = self.worktree_info_for_entry(&source, entry); + self.emit_event(EventEnvelope { + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: self.workspace_info(ws_idx), + worktree: worktree.clone(), + already_open: already_open.is_some(), + }, + }); encode_success( id, ResponseResult::WorktreeOpened { @@ -254,7 +270,7 @@ impl App { root_pane: self .root_pane_info(ws_idx, tab_idx) .expect("opened worktree workspace should have an active root pane"), - worktree: self.worktree_info_for_entry(&source, entry), + worktree, already_open: already_open.is_some(), }, ) @@ -313,6 +329,8 @@ impl App { &space.checkout_path, params.force, ); + let workspace_snapshot = self.workspace_info(ws_idx); + let worktree = self.worktree_info_for_membership(&space, None); if let Err(err) = crate::worktree::run_worktree_command(&command) { let code = if !params.force && crate::worktree::is_dirty_worktree_remove_error(&err) { "dirty_worktree_requires_force" @@ -337,9 +355,18 @@ impl App { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id: workspace_id.clone(), + workspace: Some(workspace_snapshot), }, }); } + self.emit_event(EventEnvelope { + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id: workspace_id.clone(), + worktree, + forced: params.force, + }, + }); encode_success( id, @@ -747,6 +774,25 @@ impl App { }) } + fn worktree_info_for_membership( + &self, + membership: &crate::workspace::WorktreeSpaceMembership, + open_workspace_id: Option<String>, + ) -> WorktreeInfo { + let branch = crate::workspace::git_branch(&membership.checkout_path); + let is_detached = branch.is_none(); + WorktreeInfo { + path: membership.checkout_path.display().to_string(), + branch, + is_bare: false, + is_detached, + is_prunable: false, + is_linked_worktree: membership.is_linked_worktree, + open_workspace_id, + label: membership.label.clone(), + } + } + fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> { let canonical_checkout = crate::worktree::canonical_or_original(checkout_path); let checkout_key = canonical_checkout.display().to_string(); @@ -777,7 +823,7 @@ impl App { }) } - fn emit_workspace_open_events(&self, ws_idx: usize) { + fn emit_workspace_open_events(&mut self, ws_idx: usize) { let workspace_info = self.workspace_info(ws_idx); let Some(tab) = self.tab_info(ws_idx, 0) else { return; @@ -801,7 +847,7 @@ impl App { }); } - fn emit_workspace_updated(&self, ws_idx: usize) { + fn emit_workspace_updated(&mut self, ws_idx: usize) { self.emit_event(EventEnvelope { event: EventKind::WorkspaceUpdated, data: EventData::WorkspaceUpdated { @@ -940,7 +986,14 @@ mod tests { async fn api_worktree_create_opens_workspace_and_marks_membership() { let repo = create_committed_repo("api-worktree-create-repo"); let worktree_root = unique_temp_path("api-worktree-create-root"); - let mut app = app_with_parent(&repo); + let event_hub = crate::api::EventHub::default(); + let mut app = test_app_with_event_hub(event_hub.clone()); + let mut parent = Workspace::test_new("main"); + parent.identity_cwd = repo.clone(); + app.state.workspaces = vec![parent]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; app.state.worktree_directory = worktree_root.clone(); let response = app.handle_api_request(Request { @@ -980,6 +1033,17 @@ mod tests { .is_linked_worktree ); assert!(workspace.worktree.unwrap().is_linked_worktree); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorktreeCreated { + workspace: event_workspace, + worktree: event_worktree, + } if event_workspace.workspace_id == workspace.workspace_id + && event_worktree.branch.as_deref() == Some("worktree/api-create") + && event_worktree.is_linked_worktree + ) + })); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); @@ -1140,7 +1204,11 @@ mod tests { let subdir = checkout.join("nested"); std::fs::create_dir_all(&subdir).unwrap(); - let mut app = app_with_parent(&repo); + let event_hub = crate::api::EventHub::default(); + let mut app = test_app_with_event_hub(event_hub.clone()); + let mut parent = Workspace::test_new("main"); + parent.identity_cwd = repo.clone(); + app.state.workspaces = vec![parent]; let mut child = Workspace::test_new("child"); child.identity_cwd = subdir; app.state.workspaces.push(child); @@ -1175,6 +1243,19 @@ mod tests { .unwrap() .is_linked_worktree ); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorktreeOpened { + workspace: event_workspace, + worktree: event_worktree, + already_open, + } if event_workspace.workspace_id == workspace.workspace_id + && event_worktree.branch.as_deref() == Some("worktree/api-open") + && event_worktree.is_linked_worktree + && *already_open + ) + })); let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); crate::worktree::run_worktree_command(&remove).unwrap(); @@ -1578,7 +1659,28 @@ mod tests { assert!(event_hub.events_after(0).iter().any(|(_, event)| { matches!( &event.data, - EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id + EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace), + } if workspace_id == &child_id + && workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + ) + })); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorktreeRemoved { + workspace_id, + worktree, + forced, + } if workspace_id == &child_id + && worktree.branch.as_deref() == Some("worktree/api-remove-event") + && worktree.is_linked_worktree + && worktree.open_workspace_id.is_none() + && !forced ) })); diff --git a/src/app/creation.rs b/src/app/creation.rs index 7be324e5..a45dae03 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -112,6 +112,7 @@ impl App { self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + Vec::new(), )?; let root_pane = ws.tabs[idx].root_pane; self.terminal_runtimes.insert(terminal.id.clone(), runtime); @@ -140,8 +141,17 @@ impl App { initial_cwd: PathBuf, focus: bool, ) -> std::io::Result<usize> { + self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new()) + } + + pub(crate) fn create_workspace_with_launch_env( + &mut self, + initial_cwd: PathBuf, + focus: bool, + extra_env: Vec<(String, String)>, + ) -> std::io::Result<usize> { let (rows, cols) = self.state.estimate_pane_size(); - let (ws, terminal, runtime) = Workspace::new( + let (ws, terminal, runtime) = Workspace::new_with_extra_env( initial_cwd, rows, cols, @@ -151,6 +161,7 @@ impl App { self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), + extra_env, )?; self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); diff --git a/src/app/ids.rs b/src/app/ids.rs index 5e20c388..42cf47a8 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -40,6 +40,26 @@ impl App { )) } + pub(super) fn pane_launch_env( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + extra_env: Vec<(String, String)>, + ) -> Option<crate::pane::PaneLaunchEnv> { + let workspace_id = self.public_workspace_id(ws_idx); + let ws = self.state.workspaces.get(ws_idx)?; + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; + let tab_id = self.public_tab_id(ws_idx, tab_idx)?; + let pane_id = self.public_pane_id(ws_idx, pane_id)?; + Some( + crate::pane::PaneLaunchEnv::from_extra(extra_env).with_identity( + workspace_id, + tab_id, + pane_id, + ), + ) + } + pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> { self.state .workspaces diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 1b5dfefe..339f7293 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -341,6 +341,13 @@ impl App { }; self.last_pane_click = None; + match self.invoke_plugin_link_handler_for_url(&url, info.id) { + Ok(true) => return true, + Ok(false) => {} + Err(err) => { + tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + } + } if let Err(err) = crate::platform::open_url(&url) { tracing::warn!(err = %err, url = %url, "failed to open pane URL"); } @@ -514,6 +521,7 @@ impl AppState { self.pane_scrollback_limit_bytes, self.host_terminal_theme, crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode), + Vec::new(), ) { let new_id = new_pane.pane_id; terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -622,7 +630,9 @@ fn wait_for_file(path: &std::path::Path) -> String { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < deadline { if let Ok(content) = std::fs::read_to_string(path) { - return content; + if !content.is_empty() { + return content; + } } std::thread::sleep(std::time::Duration::from_millis(20)); } diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index b27b5cb9..7072f659 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -140,6 +140,9 @@ impl App { crate::config::CustomCommandAction::Pane => { self.spawn_pane_command(&binding.command, Vec::new()) } + crate::config::CustomCommandAction::PluginAction => self + .invoke_plugin_action_from_keybind(binding.command.clone()) + .map_err(std::io::Error::other), }; match result { Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), @@ -317,7 +320,7 @@ impl App { new_cols, cwd, command, - &env, + env, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, )?; @@ -355,6 +358,92 @@ impl App { self.state.mode = Mode::Terminal; Ok(()) } + + pub(crate) fn spawn_overlay_argv_command( + &mut self, + argv: &[String], + cwd: Option<std::path::PathBuf>, + extra_env: Vec<(String, String)>, + temp_files: Vec<std::path::PathBuf>, + ) -> std::io::Result<(usize, crate::workspace::NewPane)> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let previous_focus_target = self.state.current_pane_focus_target(); + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let cwd = cwd.or_else(|| { + ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + + let (tab_idx, new_pane, workspace_id) = { + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let result = ws.split_pane_argv_command( + previous_focus, + Direction::Horizontal, + new_rows, + new_cols, + cwd, + argv, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + true, + ); + let (tab_idx, new_pane) = match result { + Some(Ok(result)) => result, + Some(Err(err)) => return Err(err), + None => return Err(std::io::Error::other("focused pane disappeared")), + }; + ws.tabs + .get_mut(tab_idx) + .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? + .zoomed = true; + self.overlay_panes.insert( + new_pane.pane_id, + super::super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + temp_files, + }, + ); + (tab_idx, new_pane, ws.id.clone()) + }; + + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id, + pane_id: new_pane.pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state.mode = Mode::Terminal; + Ok((ws_idx, new_pane)) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/app/mod.rs b/src/app/mod.rs index 5447a2de..356ecbab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -56,6 +56,13 @@ use crate::events::AppEvent; pub use state::{AppState, Mode, ToastKind, ViewState}; +pub(crate) fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result<crate::api::schema::InstalledPluginInfo, (&'static str, String)> { + api::plugins::load_plugin_manifest(path, enabled) +} + /// Full application: AppState + runtime concerns (event channels, async I/O). #[derive(Debug, Clone)] pub(crate) struct OverlayPaneState { @@ -132,7 +139,7 @@ pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64; pub(crate) enum LoopEvent { Timer, Internal(AppEvent), - Api(crate::api::ApiRequestMessage), + Api(Box<crate::api::ApiRequestMessage>), RawInput(crate::raw_input::RawInputEvent), InputClosed, RenderRequested, @@ -183,6 +190,20 @@ fn auto_updates_enabled(no_session: bool) -> bool { !no_session && !cfg!(debug_assertions) } +fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { + if no_session { + return std::collections::HashMap::new(); + } + let entries = crate::persist::plugin_registry::load(); + let entries = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { + crate::app::api::plugins::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }); + entries + .into_iter() + .map(|plugin| (plugin.plugin_id.clone(), plugin)) + .collect() +} + fn agent_panel_scope_from_config( scope: crate::config::AgentPanelScopeConfig, ) -> state::AgentPanelScope { @@ -542,6 +563,11 @@ impl App { agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), integration_install_messages: Vec::new(), + installed_plugins: load_plugin_registry(no_session), + plugin_panes: std::collections::HashMap::new(), + plugin_command_logs: Vec::new(), + next_plugin_command_log_id: 1, + plugin_commands_in_flight: 0, global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), session_dirty: false, @@ -895,7 +921,7 @@ impl App { let input_rx = self.input_rx.as_mut(); tokio::select! { maybe_api = self.api_rx.recv() => match maybe_api { - Some(msg) => LoopEvent::Api(msg), + Some(msg) => LoopEvent::Api(Box::new(msg)), None => LoopEvent::Timer, }, maybe_ev = self.event_rx.recv() => match maybe_ev { @@ -918,7 +944,7 @@ impl App { needs_render = true; } LoopEvent::Api(msg) => { - if self.handle_api_request_message(msg) { + if self.handle_api_request_message(*msg) { needs_render = true; } } @@ -3202,6 +3228,7 @@ mod tests { ratio: None, cwd: None, focus: false, + env: Default::default(), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -3281,6 +3308,7 @@ mod tests { ratio: None, cwd: None, focus: true, + env: Default::default(), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -3326,6 +3354,7 @@ mod tests { ratio: Some(0.333), cwd: None, focus: false, + env: Default::default(), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -3371,6 +3400,7 @@ mod tests { ratio: None, cwd: None, focus: false, + env: Default::default(), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -3412,6 +3442,7 @@ mod tests { split: Some(crate::api::schema::SplitDirection::Right), focus: true, argv: vec![exiting_test_command().into()], + env: Default::default(), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); diff --git a/src/app/state.rs b/src/app/state.rs index ae4295bd..ddf5c39c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -7,6 +7,15 @@ use crate::detect::AgentState; use crate::layout::{PaneId, PaneInfo, SplitBorder}; use crate::selection::Selection; +pub(crate) type InstalledPluginRegistry = + std::collections::HashMap<String, crate::api::schema::InstalledPluginInfo>; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PluginPaneRecord { + pub plugin_id: String, + pub entrypoint: String, +} + // --------------------------------------------------------------------------- // Selection autoscroll types // --------------------------------------------------------------------------- @@ -1384,6 +1393,14 @@ pub struct AppState { pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus, /// Result messages from the latest integration install action. pub integration_install_messages: Vec<String>, + /// Installed or linked plugins known to this running Herdr instance. + pub(crate) installed_plugins: InstalledPluginRegistry, + /// Pane ids opened through the plugin pane API. + pub(crate) plugin_panes: std::collections::HashMap<PaneId, PluginPaneRecord>, + /// Recent plugin action/event command executions. + pub(crate) plugin_command_logs: Vec<crate::api::schema::PluginCommandLogInfo>, + pub(crate) next_plugin_command_log_id: u64, + pub(crate) plugin_commands_in_flight: usize, /// Highlight state for the bottom-right global launcher menu. pub global_menu: MenuListState, /// Resolved host terminal default colors for theming embedded panes. @@ -1714,6 +1731,11 @@ impl AppState { agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus::default(), integration_install_messages: Vec::new(), + installed_plugins: std::collections::HashMap::new(), + plugin_panes: std::collections::HashMap::new(), + plugin_command_logs: Vec::new(), + next_plugin_command_log_id: 1, + plugin_commands_in_flight: 0, global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), session_dirty: false, @@ -4,20 +4,34 @@ use serde::Serialize; use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ - AgentStatus, Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, - ReadSource, Request, SplitDirection, Subscription, + AgentStatus, ClientWindowTitleSetParams, EmptyParams, Method, OutputMatch, PaneAgentState, + PaneWaitForOutputParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, }; mod agent; mod integration; mod notification; mod pane; +mod plugin; mod server; mod status; mod tab; mod workspace; mod worktree; +pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> { + let Some((key, value)) = raw.split_once('=') else { + return Err("env must use KEY=VALUE".into()); + }; + if key.is_empty() { + return Err("env key must not be empty".into()); + } + if key.contains('\0') || value.contains('\0') { + return Err("env must not contain NUL bytes".into()); + } + Ok((key.to_string(), value.to_string())) +} + pub enum CommandOutcome { Handled(i32), NotCli, @@ -45,6 +59,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "agent" => agent::run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, "pane" => pane::run_pane_command(&args[2..])?, + "plugin" => plugin::run_plugin_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, @@ -305,6 +320,7 @@ fn run_terminal_command(args: &[String]) -> std::io::Result<i32> { match subcommand { "attach" => terminal_attach(&args[1..]), + "title" => terminal_title(&args[1..]), "help" | "--help" | "-h" => { print_terminal_help(); Ok(0) @@ -458,6 +474,43 @@ fn terminal_attach(args: &[String]) -> std::io::Result<i32> { Ok(0) } +fn terminal_title(args: &[String]) -> std::io::Result<i32> { + match args.first().map(|arg| arg.as_str()) { + Some("set") => { + if args.len() != 2 { + eprintln!("usage: herdr terminal title set <title>"); + return Ok(2); + } + print_response(&send_request(&Request { + id: "cli:terminal:title:set".into(), + method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams { + title: args[1].clone(), + }), + })?) + } + Some("clear") => { + if args.len() != 1 { + eprintln!("usage: herdr terminal title clear"); + return Ok(2); + } + print_response(&send_request(&Request { + id: "cli:terminal:title:clear".into(), + method: Method::ClientWindowTitleClear(EmptyParams::default()), + })?) + } + Some("help" | "--help" | "-h") => { + eprintln!("usage: herdr terminal title set <title>"); + eprintln!(" herdr terminal title clear"); + Ok(0) + } + _ => { + eprintln!("usage: herdr terminal title set <title>"); + eprintln!(" herdr terminal title clear"); + Ok(2) + } + } +} + pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> { let Some(target) = args.first() else { eprintln!("{usage}"); @@ -861,6 +914,8 @@ fn print_config_help() { fn print_terminal_help() { eprintln!("herdr terminal commands:"); eprintln!(" herdr terminal attach <terminal_id> [--takeover]"); + eprintln!(" herdr terminal title set <title>"); + eprintln!(" herdr terminal title clear"); eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); } @@ -948,4 +1003,20 @@ mod tests { super::ChannelSetInstallAction::RunSelfUpdate ); } + + #[test] + fn parse_env_assignment_accepts_empty_values() { + assert_eq!( + super::parse_env_assignment("HERDR_ROLE=").unwrap(), + ("HERDR_ROLE".to_string(), String::new()) + ); + } + + #[test] + fn parse_env_assignment_requires_key_value_separator() { + assert_eq!( + super::parse_env_assignment("HERDR_ROLE").unwrap_err(), + "env must use KEY=VALUE" + ); + } } diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 8c3c907b..80148046 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::api::schema::{ AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription, @@ -267,12 +269,12 @@ fn matched_rule_region_preview<'a>( fn agent_start(args: &[String]) -> std::io::Result<i32> { let Some(name) = args.first() else { - eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>"); return Ok(2); }; let Some(separator) = args.iter().position(|arg| arg == "--") else { - eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>"); return Ok(2); }; if separator == args.len() - 1 { @@ -285,6 +287,7 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { let mut tab_id = None; let mut split = None; let mut focus = false; + let mut env = HashMap::new(); let mut index = 1; while index < separator { @@ -329,6 +332,21 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } + "--env" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --env"); + return Ok(2); + }; + let (key, value) = match super::parse_env_assignment(value) { + Ok(pair) => pair, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + env.insert(key, value); + index += 2; + } other => { eprintln!("unknown option: {other}"); return Ok(2); @@ -346,6 +364,7 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { split, focus, argv: args[separator + 1..].to_vec(), + env, }), })?) } @@ -656,7 +675,7 @@ fn print_agent_help() { eprintln!(" herdr agent focus <target>"); eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); eprintln!(" herdr agent attach <target> [--takeover]"); - eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>"); eprintln!(" herdr agent explain <target> [--json]"); eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]"); eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 5f0b42f2..2e5ce485 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,10 +1,11 @@ use crate::api::schema::{ - Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams, - PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneReadParams, - PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, - PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, - PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, - ReadFormat, ReadSource, Request, SplitDirection, + Method, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, + PaneLayoutParams, PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, + PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams, + PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, + PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, + SplitDirection, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -15,8 +16,10 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { match subcommand { "list" => pane_list(&args[1..]), + "current" => pane_current(&args[1..]), "get" => pane_get(&args[1..]), "layout" => pane_layout(&args[1..]), + "process-info" => pane_process_info(&args[1..]), "neighbor" => pane_neighbor(&args[1..]), "edges" => pane_edges(&args[1..]), "focus" => pane_focus(&args[1..]), @@ -91,6 +94,49 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> { })?) } +fn pane_current(args: &[String]) -> std::io::Result<i32> { + let env_pane_id = std::env::var("HERDR_PANE_ID") + .ok() + .filter(|value| !value.trim().is_empty()); + let caller_pane_id = match parse_pane_current_args(args, env_pane_id.as_deref()) { + Ok(caller_pane_id) => caller_pane_id, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:current".into(), + method: Method::PaneCurrent(PaneCurrentParams { caller_pane_id }), + })?) +} + +fn parse_pane_current_args( + args: &[String], + env_pane_id: Option<&str>, +) -> Result<Option<String>, String> { + let mut caller_pane_id = env_pane_id.map(super::normalize_pane_id); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + caller_pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + caller_pane_id = env_pane_id.map(super::normalize_pane_id); + index += 1; + } + other => return Err(format!("unknown option: {other}")), + } + } + Ok(caller_pane_id) +} + fn pane_layout(args: &[String]) -> std::io::Result<i32> { let pane_id = match parse_optional_current_pane_args(args) { Ok(pane_id) => pane_id, @@ -106,6 +152,21 @@ fn pane_layout(args: &[String]) -> std::io::Result<i32> { })?) } +fn pane_process_info(args: &[String]) -> std::io::Result<i32> { + let pane_id = match parse_optional_current_pane_args(args) { + Ok(pane_id) => pane_id, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:process_info".into(), + method: Method::PaneProcessInfo(PaneProcessInfoParams { pane_id }), + })?) +} + fn pane_edges(args: &[String]) -> std::io::Result<i32> { let pane_id = match parse_optional_current_pane_args(args) { Ok(pane_id) => pane_id, @@ -483,6 +544,7 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> { } fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> { + let mut env = std::collections::HashMap::new(); let mut pane_id = None; let mut direction = None; let mut ratio = None; @@ -546,13 +608,21 @@ fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> { focus = false; index += 1; } + "--env" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --env".into()); + }; + let (key, value) = super::parse_env_assignment(value)?; + env.insert(key, value); + index += 2; + } other => return Err(format!("unknown option: {other}")), } } let Some(direction) = direction else { return Err( - "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]" + "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]" .into(), ); }; @@ -564,6 +634,7 @@ fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> { ratio, cwd, focus, + env, }) } @@ -1348,8 +1419,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace <workspace_id>]"); + eprintln!(" herdr pane current [--pane ID|--current]"); eprintln!(" herdr pane get <pane_id>"); eprintln!(" herdr pane layout [--pane ID|--current]"); + eprintln!(" herdr pane process-info [--pane ID|--current]"); eprintln!(" herdr pane neighbor --direction left|right|up|down [--pane ID|--current]"); eprintln!(" herdr pane edges [--pane ID|--current]"); eprintln!(" herdr pane focus --direction left|right|up|down [--pane ID|--current]"); @@ -1360,7 +1433,7 @@ fn print_pane_help() { eprintln!(" herdr pane rename <pane_id> <label>|--clear"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!( - " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]" + " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]" ); eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]"); eprintln!(" herdr pane swap --source-pane ID --target-pane ID"); @@ -1419,6 +1492,35 @@ mod tests { } #[test] + fn parse_pane_current_args_uses_env_pane_by_default() { + let pane_id = parse_pane_current_args(&args(&[]), Some("issue-1")).unwrap(); + + assert_eq!(pane_id, Some("issue-1".into())); + } + + #[test] + fn parse_pane_current_args_accepts_explicit_pane() { + let pane_id = + parse_pane_current_args(&args(&["--pane", "issue-2"]), Some("issue-1")).unwrap(); + + assert_eq!(pane_id, Some("issue-2".into())); + } + + #[test] + fn parse_pane_current_args_current_keeps_env_pane() { + let pane_id = parse_pane_current_args(&args(&["--current"]), Some("issue-1")).unwrap(); + + assert_eq!(pane_id, Some("issue-1".into())); + } + + #[test] + fn parse_pane_current_args_without_env_falls_back_to_focused_pane() { + let pane_id = parse_pane_current_args(&args(&[]), None).unwrap(); + + assert_eq!(pane_id, None); + } + + #[test] fn parse_pane_swap_args_accepts_directional_current() { let params = parse_pane_swap_args(&args(&["--direction", "right"])).unwrap(); diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs new file mode 100644 index 00000000..081a27bd --- /dev/null +++ b/src/cli/plugin.rs @@ -0,0 +1,1694 @@ +use std::collections::HashMap; +use std::fmt; +use std::io::{self, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::api::schema::{ + InstalledPluginInfo, Method, PluginActionInvokeParams, PluginActionListParams, + PluginInvocationContext, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement, + PluginPlatform, PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, + Request, ResponseResult, SplitDirection, SuccessResponse, +}; + +const PLUGIN_BUILD_OUTPUT_MAX_BYTES: usize = 64 * 1024; + +pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_plugin_help(); + return Ok(2); + }; + + match subcommand { + "install" => plugin_install(&args[1..]), + "uninstall" => plugin_uninstall(&args[1..]), + "link" => plugin_link(&args[1..]), + "list" => plugin_list(&args[1..]), + "unlink" => plugin_unlink(&args[1..]), + "enable" => plugin_set_enabled(&args[1..], true), + "disable" => plugin_set_enabled(&args[1..], false), + "action" => run_plugin_action_command(&args[1..]), + "log" | "logs" => plugin_log_list(&args[1..]), + "pane" => run_plugin_pane_command(&args[1..]), + "help" | "--help" | "-h" => { + print_plugin_help(); + Ok(0) + } + _ => { + print_plugin_help(); + Ok(2) + } + } +} + +fn plugin_link(args: &[String]) -> std::io::Result<i32> { + let Some(path) = args.first() else { + eprintln!("usage: herdr plugin link <path> [--disabled]"); + return Ok(2); + }; + let path = normalize_plugin_path_arg(path)?; + let mut enabled = true; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--disabled" => { + enabled = false; + index += 1; + } + "--enabled" => { + enabled = true; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + print_plugin_response(Method::PluginLink(PluginLinkParams { + path, + enabled, + source: None, + })) +} + +fn plugin_list(args: &[String]) -> std::io::Result<i32> { + let mut plugin_id = None; + let mut json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--json" => { + json = true; + index += 1; + } + "--plugin" => { + let Some(value) = required_value(args, &mut index, "--plugin") else { + return Ok(2); + }; + plugin_id = Some(value); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + let params = PluginListParams { plugin_id }; + let response = match super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginList(params.clone()), + }) { + Ok(response) => response, + Err(err) if is_connection_error(&err) => offline_plugin_list_response(¶ms)?, + Err(err) => return Err(err), + }; + if json { + return super::print_response(&response); + } + print_plugin_list_human(&response) +} + +fn plugin_unlink(args: &[String]) -> std::io::Result<i32> { + let Some(plugin_id) = args.first() else { + eprintln!("usage: herdr plugin unlink <plugin_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin unlink <plugin_id>"); + return Ok(2); + } + print_plugin_response(Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin_id.clone(), + })) +} + +fn plugin_install(args: &[String]) -> std::io::Result<i32> { + let Some(source_arg) = args.first() else { + eprintln!("usage: herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]"); + return Ok(2); + }; + let source = match GithubPluginSource::parse(source_arg) { + Ok(source) => source, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + let mut requested_ref = None; + let mut yes = false; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--ref" => { + let Some(value) = required_value(args, &mut index, "--ref") else { + return Ok(2); + }; + requested_ref = Some(value); + } + "--yes" | "-y" => { + yes = true; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + if !yes && !io::stdin().is_terminal() { + eprintln!("remote plugin install requires --yes when stdin is not interactive"); + return Ok(2); + } + + let temp_root = create_plugin_temp_dir("install")?; + let checkout = temp_root.join("checkout"); + let install_result = (|| { + git_checkout(&source, requested_ref.as_deref(), &checkout)?; + let resolved_commit = git_output(&checkout, ["rev-parse", "HEAD"])?; + let manifest_root = source.manifest_root(&checkout); + let preview_plugin = load_cli_plugin_manifest(&manifest_root, true)?; + let existing = installed_plugin_info(&preview_plugin.plugin_id)?; + ensure_replacement_allowed(&preview_plugin, existing.as_ref())?; + + let mut source_info = + source.to_source_info(requested_ref, resolved_commit, None, current_unix_ms()); + print_install_preview(&preview_plugin, &source_info, existing.as_ref()); + if !yes && !confirm("Install this plugin?")? { + eprintln!("plugin install cancelled"); + return Ok(0); + } + if let Err(err) = run_plugin_build_commands(&preview_plugin, &manifest_root) { + eprintln!("{err}"); + return Ok(1); + } + let post_build_plugin = load_cli_plugin_manifest(&manifest_root, true)?; + ensure_manifest_unchanged_after_build(&preview_plugin, &post_build_plugin)?; + + let final_checkout = managed_checkout_path(&preview_plugin.plugin_id); + let backup_checkout = temp_root.join("previous-checkout"); + let mut backup_moved = false; + if final_checkout.exists() { + std::fs::rename(&final_checkout, &backup_checkout)?; + backup_moved = true; + } + let install_attempt = (|| { + if let Some(parent) = final_checkout.parent() { + std::fs::create_dir_all(parent).map_err(InstallFailure::Rollback)?; + } + std::fs::rename(&checkout, &final_checkout).map_err(InstallFailure::Rollback)?; + + source_info.managed_path = Some(final_checkout.display().to_string()); + let final_manifest_root = source.manifest_root(&final_checkout); + let mut plugin = load_cli_plugin_manifest(&final_manifest_root, true) + .map_err(InstallFailure::Rollback)?; + plugin.source = source_info.clone(); + register_installed_plugin(plugin.clone(), source_info.clone())?; + Ok::<InstalledPluginInfo, InstallFailure>(plugin) + })(); + let plugin = match install_attempt { + Ok(plugin) => plugin, + Err(InstallFailure::Rollback(err)) => { + let _ = std::fs::remove_dir_all(&final_checkout); + if backup_moved && backup_checkout.exists() { + let _ = std::fs::rename(&backup_checkout, &final_checkout); + } + return Err(err); + } + Err(InstallFailure::KeepCheckout(err)) => return Err(err), + }; + println!("Installed {} from {}.", plugin.plugin_id, source.display()); + Ok(0) + })(); + let _ = std::fs::remove_dir_all(&temp_root); + install_result +} + +fn plugin_uninstall(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>"); + return Ok(2); + } + + let (plugin_id, existing) = match GithubPluginSource::parse(target) { + Ok(source) => { + let existing = installed_plugin_by_github_source(&source)?; + let Some(existing) = existing else { + eprintln!("plugin not installed: {}", source.display()); + return Ok(1); + }; + (existing.plugin_id.clone(), Some(existing)) + } + Err(_) => { + let existing = match live_installed_plugin_info(target) { + Ok(plugin) => plugin, + Err(err) if is_connection_error(&err) => registry_plugin_info(target), + Err(err) => return Err(err), + }; + (target.clone(), existing) + } + }; + + match super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin_id.clone(), + }), + }) { + Ok(response) => { + if response.get("error").is_some() { + return super::print_response(&response); + } + if response["result"]["removed"].as_bool() == Some(false) { + eprintln!("plugin not installed: {target}"); + return Ok(1); + } + } + Err(err) if is_connection_error(&err) => { + let mut plugins = crate::persist::plugin_registry::load(); + let before = plugins.len(); + plugins.retain(|plugin| plugin.plugin_id != plugin_id); + if before == plugins.len() { + eprintln!("plugin not installed: {target}"); + return Ok(1); + } + crate::persist::plugin_registry::save(&plugins)?; + } + Err(err) => return Err(err), + } + + if let Some(plugin) = existing.as_ref() { + remove_managed_plugin_files(plugin)?; + } + println!("Uninstalled {plugin_id}."); + Ok(0) +} + +fn plugin_set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> { + let Some(plugin_id) = args.first() else { + eprintln!( + "usage: herdr plugin {} <plugin_id>", + if enabled { "enable" } else { "disable" } + ); + return Ok(2); + }; + if args.len() != 1 { + eprintln!( + "usage: herdr plugin {} <plugin_id>", + if enabled { "enable" } else { "disable" } + ); + return Ok(2); + } + let params = PluginSetEnabledParams { + plugin_id: plugin_id.clone(), + }; + if enabled { + print_plugin_response(Method::PluginEnable(params)) + } else { + print_plugin_response(Method::PluginDisable(params)) + } +} + +fn plugin_log_list(args: &[String]) -> std::io::Result<i32> { + let mut plugin_id = None; + let mut limit = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "list" if index == 0 => index += 1, + "--plugin" => { + let Some(value) = required_value(args, &mut index, "--plugin") else { + return Ok(2); + }; + plugin_id = Some(value); + } + "--limit" => { + let Some(raw) = required_value(args, &mut index, "--limit") else { + return Ok(2); + }; + let Ok(parsed) = raw.parse::<usize>() else { + eprintln!("invalid --limit value: {raw}"); + return Ok(2); + }; + limit = Some(parsed); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + print_plugin_response(Method::PluginLogList(PluginLogListParams { + plugin_id, + limit, + })) +} + +fn run_plugin_action_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_plugin_action_help(); + return Ok(2); + }; + + match subcommand { + "list" => plugin_action_list(&args[1..]), + "invoke" => plugin_action_invoke(&args[1..]), + "help" | "--help" | "-h" => { + print_plugin_action_help(); + Ok(0) + } + _ => { + print_plugin_action_help(); + Ok(2) + } + } +} + +fn plugin_action_list(args: &[String]) -> std::io::Result<i32> { + let mut plugin_id = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--plugin" => { + let Some(value) = required_value(args, &mut index, "--plugin") else { + return Ok(2); + }; + plugin_id = Some(value); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_plugin_response(Method::PluginActionList(PluginActionListParams { + plugin_id, + })) +} + +fn plugin_action_invoke(args: &[String]) -> std::io::Result<i32> { + let Some(action_id) = args.first() else { + eprintln!("usage: herdr plugin action invoke <action_id> [--plugin ID]"); + return Ok(2); + }; + let mut plugin_id = None; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--plugin" => { + let Some(value) = required_value(args, &mut index, "--plugin") else { + return Ok(2); + }; + plugin_id = Some(value); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_plugin_response(Method::PluginActionInvoke(PluginActionInvokeParams { + action_id: action_id.clone(), + plugin_id, + context: Some(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("cli".into()), + correlation_id: None, + clicked_url: None, + link_handler_id: None, + }), + })) +} + +fn run_plugin_pane_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_plugin_pane_help(); + return Ok(2); + }; + + match subcommand { + "open" => plugin_pane_open(&args[1..]), + "focus" => plugin_pane_focus(&args[1..]), + "close" => plugin_pane_close(&args[1..]), + "help" | "--help" | "-h" => { + print_plugin_pane_help(); + Ok(0) + } + _ => { + print_plugin_pane_help(); + Ok(2) + } + } +} + +fn plugin_pane_open(args: &[String]) -> std::io::Result<i32> { + let mut plugin_id = None; + let mut entrypoint = None; + let mut placement = None; + let mut workspace_id = None; + let mut target_pane_id = None; + let mut direction = None; + let mut cwd = None; + let mut focus = true; + let mut env = HashMap::new(); + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--plugin" => { + let Some(value) = required_value(args, &mut index, "--plugin") else { + return Ok(2); + }; + plugin_id = Some(value); + } + "--entrypoint" => { + let Some(value) = required_value(args, &mut index, "--entrypoint") else { + return Ok(2); + }; + entrypoint = Some(value); + } + "--placement" => { + let Some(value) = required_value(args, &mut index, "--placement") else { + return Ok(2); + }; + let Some(parsed) = parse_pane_placement(&value) else { + return Ok(2); + }; + placement = Some(parsed); + } + "--workspace" => { + let Some(value) = required_value(args, &mut index, "--workspace") else { + return Ok(2); + }; + workspace_id = Some(value); + } + "--target-pane" => { + let Some(value) = required_value(args, &mut index, "--target-pane") else { + return Ok(2); + }; + target_pane_id = Some(value); + } + "--direction" => { + let Some(value) = required_value(args, &mut index, "--direction") else { + return Ok(2); + }; + let Some(parsed) = parse_split_direction(&value) else { + return Ok(2); + }; + direction = Some(parsed); + } + "--cwd" => { + let Some(value) = required_value(args, &mut index, "--cwd") else { + return Ok(2); + }; + cwd = Some(value); + } + "--env" => { + let Some(value) = required_value(args, &mut index, "--env") else { + return Ok(2); + }; + let (key, value) = match super::parse_env_assignment(&value) { + Ok(pair) => pair, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + env.insert(key, value); + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(plugin_id) = plugin_id else { + eprintln!("missing required --plugin"); + return Ok(2); + }; + let Some(entrypoint) = entrypoint else { + eprintln!("missing required --entrypoint"); + return Ok(2); + }; + + print_plugin_response(Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id, + entrypoint, + placement, + workspace_id, + target_pane_id, + direction, + cwd, + focus, + env, + })) +} + +fn plugin_pane_focus(args: &[String]) -> std::io::Result<i32> { + let Some(pane_id) = args.first() else { + eprintln!("usage: herdr plugin pane focus <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin pane focus <pane_id>"); + return Ok(2); + } + print_plugin_response(Method::PluginPaneFocus(PluginPaneFocusParams { + pane_id: super::normalize_pane_id(pane_id), + })) +} + +fn plugin_pane_close(args: &[String]) -> std::io::Result<i32> { + let Some(pane_id) = args.first() else { + eprintln!("usage: herdr plugin pane close <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin pane close <pane_id>"); + return Ok(2); + } + print_plugin_response(Method::PluginPaneClose(PluginPaneCloseParams { + pane_id: super::normalize_pane_id(pane_id), + })) +} + +fn required_value(args: &[String], index: &mut usize, flag: &str) -> Option<String> { + let Some(value) = args.get(*index + 1) else { + eprintln!("missing value for {flag}"); + return None; + }; + *index += 2; + Some(value.clone()) +} + +fn parse_pane_placement(value: &str) -> Option<PluginPanePlacement> { + match value { + "overlay" => Some(PluginPanePlacement::Overlay), + "split" => Some(PluginPanePlacement::Split), + "tab" => Some(PluginPanePlacement::Tab), + "zoomed" | "fullscreen" => Some(PluginPanePlacement::Zoomed), + _ => { + eprintln!("invalid pane placement: {value}"); + None + } + } +} + +fn parse_split_direction(value: &str) -> Option<SplitDirection> { + match value { + "right" => Some(SplitDirection::Right), + "down" => Some(SplitDirection::Down), + _ => { + eprintln!("invalid split direction: {value}"); + None + } + } +} + +fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> { + let path = crate::worktree::expand_tilde_path(value); + let absolute = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + Ok(absolute.display().to_string()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GithubPluginSource { + owner: String, + repo: String, + subdir: Option<String>, +} + +impl GithubPluginSource { + fn parse(value: &str) -> Result<Self, String> { + if value.starts_with("http://") + || value.starts_with("https://") + || value.starts_with("git@") + || value.contains(':') + { + return Err("plugin install v1 accepts only owner/repo[/subdir] shorthand".into()); + } + let parts = value.split('/').collect::<Vec<_>>(); + if parts.len() < 2 { + return Err("usage: herdr plugin install <owner>/<repo>[/subdir...]".into()); + } + let owner = parts[0]; + let repo = parts[1]; + validate_github_segment("owner", owner)?; + validate_github_segment("repo", repo)?; + let subdir_parts = &parts[2..]; + for part in subdir_parts { + validate_subdir_segment(part)?; + } + let subdir = if subdir_parts.is_empty() { + None + } else { + Some(subdir_parts.join("/")) + }; + Ok(Self { + owner: owner.to_string(), + repo: repo.to_string(), + subdir, + }) + } + + fn remote_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.repo) + } + + fn display(&self) -> String { + match &self.subdir { + Some(subdir) => format!("{}/{}/{}", self.owner, self.repo, subdir), + None => format!("{}/{}", self.owner, self.repo), + } + } + + fn manifest_root(&self, checkout: &Path) -> PathBuf { + match &self.subdir { + Some(subdir) => checkout.join(subdir), + None => checkout.to_path_buf(), + } + } + + fn to_source_info( + &self, + requested_ref: Option<String>, + resolved_commit: String, + managed_path: Option<String>, + installed_unix_ms: u64, + ) -> PluginSourceInfo { + PluginSourceInfo { + kind: PluginSourceKind::Github, + owner: Some(self.owner.clone()), + repo: Some(self.repo.clone()), + subdir: self.subdir.clone(), + requested_ref, + resolved_commit: Some(resolved_commit), + managed_path, + installed_unix_ms: Some(installed_unix_ms), + } + } +} + +fn ensure_replacement_allowed( + plugin: &InstalledPluginInfo, + existing: Option<&InstalledPluginInfo>, +) -> std::io::Result<()> { + let Some(existing) = existing else { + return Ok(()); + }; + if existing.source.kind != PluginSourceKind::Github { + return Err(std::io::Error::other(format!( + "plugin {} is already linked from a local path; uninstall/unlink it before installing from GitHub", + plugin.plugin_id + ))); + } + Ok(()) +} + +fn validate_github_segment(label: &str, value: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("GitHub {label} must not be empty")); + } + if value == "." || value == ".." { + return Err(format!("GitHub {label} is invalid: {value}")); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return Err(format!( + "GitHub {label} contains invalid characters: {value}" + )); + } + Ok(()) +} + +fn validate_subdir_segment(value: &str) -> Result<(), String> { + if value.is_empty() || value == "." || value == ".." { + return Err(format!("invalid plugin subdir segment: {value}")); + } + if value.contains('\\') || value.contains('\0') { + return Err(format!("invalid plugin subdir segment: {value}")); + } + Ok(()) +} + +fn git_checkout( + source: &GithubPluginSource, + requested_ref: Option<&str>, + checkout: &Path, +) -> std::io::Result<()> { + std::fs::create_dir_all(checkout)?; + run_git(Some(checkout), ["init"])?; + run_git( + Some(checkout), + ["remote", "add", "origin", &source.remote_url()], + )?; + match requested_ref { + Some(reference) => { + run_git( + Some(checkout), + ["fetch", "--depth", "1", "origin", reference], + )?; + } + None => { + run_git(Some(checkout), ["fetch", "--depth", "1", "origin", "HEAD"])?; + } + } + run_git(Some(checkout), ["checkout", "--detach", "FETCH_HEAD"])?; + Ok(()) +} + +fn run_git<const N: usize>(cwd: Option<&Path>, args: [&str; N]) -> std::io::Result<()> { + let mut command = Command::new("git"); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.stdin(Stdio::null()); + let output = command.output()?; + if output.status.success() { + return Ok(()); + } + Err(std::io::Error::other(command_failure_message( + "git", &output, + ))) +} + +fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> std::io::Result<String> { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .output()?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + Err(std::io::Error::other(command_failure_message( + "git", &output, + ))) +} + +fn command_failure_message(program: &str, output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + format!("{program} failed with status {}", output.status) + } else { + format!("{program} failed with status {}: {stderr}", output.status) + } +} + +fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result<InstalledPluginInfo> { + crate::app::load_plugin_manifest(&path.display().to_string(), enabled) + .map_err(|(_, message)| std::io::Error::other(message)) +} + +fn register_installed_plugin( + plugin: InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result<(), InstallFailure> { + let request = Request { + id: "cli:plugin".into(), + method: Method::PluginLink(PluginLinkParams { + path: plugin.manifest_path.clone(), + enabled: plugin.enabled, + source: Some(source.clone()), + }), + }; + match super::send_request(&request) { + Ok(response) => { + if response.get("error").is_some() { + return Err(InstallFailure::Rollback(std::io::Error::other( + serde_json::to_string(&response).unwrap(), + ))); + } + if let Err(err) = + verify_plugin_link_source_response(response, &plugin.plugin_id, &source) + { + let unlink = super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin.plugin_id.clone(), + }), + }); + match unlink { + Ok(response) if response.get("error").is_none() => { + return Err(InstallFailure::Rollback(err)); + } + Ok(response) => { + return Err(InstallFailure::KeepCheckout(std::io::Error::other( + format!( + "{err}; failed to undo incompatible plugin registration: {}", + serde_json::to_string(&response).unwrap() + ), + ))); + } + Err(unlink_err) => { + return Err(InstallFailure::KeepCheckout(std::io::Error::other( + format!( + "{err}; failed to undo incompatible plugin registration: {unlink_err}" + ), + ))); + } + } + } + Ok(()) + } + Err(err) if is_connection_error(&err) => { + let mut plugins = crate::persist::plugin_registry::load(); + plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); + plugins.push(plugin); + crate::persist::plugin_registry::save(&plugins).map_err(InstallFailure::Rollback) + } + Err(err) => Err(InstallFailure::Rollback(err)), + } +} + +#[derive(Debug)] +enum InstallFailure { + Rollback(std::io::Error), + KeepCheckout(std::io::Error), +} + +fn verify_plugin_link_source_response( + response: serde_json::Value, + plugin_id: &str, + expected: &PluginSourceInfo, +) -> std::io::Result<()> { + let parsed: SuccessResponse = + serde_json::from_value(response).map_err(std::io::Error::other)?; + let ResponseResult::PluginLinked { plugin } = parsed.result else { + return Err(std::io::Error::other("expected plugin_linked response")); + }; + if plugin.plugin_id != plugin_id + || plugin.source.kind != PluginSourceKind::Github + || plugin.source.owner != expected.owner + || plugin.source.repo != expected.repo + || plugin.source.subdir != expected.subdir + || plugin.source.requested_ref != expected.requested_ref + || plugin.source.resolved_commit != expected.resolved_commit + || plugin.source.managed_path != expected.managed_path + { + return Err(std::io::Error::other( + "running Herdr server did not persist GitHub plugin source metadata", + )); + } + Ok(()) +} + +fn installed_plugin_info(plugin_id: &str) -> std::io::Result<Option<InstalledPluginInfo>> { + match live_installed_plugin_info(plugin_id) { + Ok(plugin) => Ok(plugin), + Err(err) if is_connection_error(&err) => Ok(registry_plugin_info(plugin_id)), + Err(err) => Err(err), + } +} + +fn live_installed_plugin_info(plugin_id: &str) -> std::io::Result<Option<InstalledPluginInfo>> { + let response = super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginList(PluginListParams { + plugin_id: Some(plugin_id.to_string()), + }), + })?; + if response.get("error").is_some() { + return Err(std::io::Error::other( + serde_json::to_string(&response).unwrap(), + )); + } + plugin_info_from_list_response(response) +} + +fn installed_plugin_by_github_source( + source: &GithubPluginSource, +) -> std::io::Result<Option<InstalledPluginInfo>> { + let plugins = match live_installed_plugins() { + Ok(plugins) => plugins, + Err(err) if is_connection_error(&err) => registry_plugins(), + Err(err) => return Err(err), + }; + Ok(plugin_by_github_source(plugins, source)) +} + +fn live_installed_plugins() -> std::io::Result<Vec<InstalledPluginInfo>> { + let response = super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginList(PluginListParams { plugin_id: None }), + })?; + if response.get("error").is_some() { + return Err(std::io::Error::other( + serde_json::to_string(&response).unwrap(), + )); + } + plugin_list_from_response(response) +} + +fn registry_plugin_info(plugin_id: &str) -> Option<InstalledPluginInfo> { + registry_plugins() + .into_iter() + .find(|plugin| plugin.plugin_id == plugin_id) +} + +fn registry_plugins() -> Vec<InstalledPluginInfo> { + crate::persist::plugin_registry::load() +} + +fn plugin_info_from_list_response( + response: serde_json::Value, +) -> std::io::Result<Option<InstalledPluginInfo>> { + let mut plugins = plugin_list_from_response(response)?; + Ok(plugins.pop()) +} + +fn plugin_list_from_response( + response: serde_json::Value, +) -> std::io::Result<Vec<InstalledPluginInfo>> { + let parsed: SuccessResponse = + serde_json::from_value(response).map_err(std::io::Error::other)?; + let ResponseResult::PluginList { mut plugins } = parsed.result else { + return Err(std::io::Error::other("expected plugin_list response")); + }; + plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + Ok(plugins) +} + +fn plugin_by_github_source( + plugins: impl IntoIterator<Item = InstalledPluginInfo>, + source: &GithubPluginSource, +) -> Option<InstalledPluginInfo> { + plugins + .into_iter() + .find(|plugin| plugin_matches_github_source(plugin, source)) +} + +fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPluginSource) -> bool { + plugin.source.kind == PluginSourceKind::Github + && plugin.source.owner.as_deref() == Some(source.owner.as_str()) + && plugin.source.repo.as_deref() == Some(source.repo.as_str()) + && plugin.source.subdir.as_deref() == source.subdir.as_deref() +} + +fn offline_plugin_list_response(params: &PluginListParams) -> std::io::Result<serde_json::Value> { + let entries = crate::persist::plugin_registry::load(); + let mut plugins = + crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { + crate::app::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }) + .into_iter() + .filter(|plugin| { + params + .plugin_id + .as_deref() + .is_none_or(|plugin_id| plugin.plugin_id == plugin_id) + }) + .collect::<Vec<_>>(); + plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + serde_json::to_value(SuccessResponse { + id: "cli:plugin".into(), + result: ResponseResult::PluginList { plugins }, + }) + .map_err(std::io::Error::other) +} + +fn print_plugin_list_human(response: &serde_json::Value) -> std::io::Result<i32> { + if response.get("error").is_some() { + return super::print_response(response); + } + let parsed: SuccessResponse = + serde_json::from_value(response.clone()).map_err(std::io::Error::other)?; + let ResponseResult::PluginList { plugins } = parsed.result else { + return super::print_response(response); + }; + if plugins.is_empty() { + println!("No plugins installed."); + return Ok(0); + } + println!( + "{} plugin{} installed:", + plugins.len(), + if plugins.len() == 1 { "" } else { "s" } + ); + for plugin in plugins { + let enabled = if plugin.enabled { + "enabled" + } else { + "disabled" + }; + let warning = if plugin.warnings.is_empty() { + String::new() + } else { + format!("; {} warning(s)", plugin.warnings.len()) + }; + println!( + "- {} ({}) {} [{}{}]", + plugin.plugin_id, + plugin.name, + enabled, + source_display(&plugin), + warning + ); + for warning in plugin.warnings { + println!(" warning: {warning}"); + } + } + Ok(0) +} + +fn source_display(plugin: &InstalledPluginInfo) -> String { + match plugin.source.kind { + PluginSourceKind::Github => { + let owner = plugin.source.owner.as_deref().unwrap_or("unknown"); + let repo = plugin.source.repo.as_deref().unwrap_or("unknown"); + let subdir = plugin + .source + .subdir + .as_deref() + .map(|subdir| format!("/{subdir}")) + .unwrap_or_default(); + let reference = plugin + .source + .requested_ref + .as_deref() + .or(plugin.source.resolved_commit.as_deref()) + .unwrap_or("unknown"); + format!("github:{owner}/{repo}{subdir}@{reference}") + } + PluginSourceKind::Local => format!("local:{}", plugin.plugin_root), + } +} + +fn print_install_preview( + plugin: &InstalledPluginInfo, + source: &PluginSourceInfo, + existing: Option<&InstalledPluginInfo>, +) { + eprintln!("Plugin install preview:"); + eprintln!(" id: {}", plugin.plugin_id); + eprintln!(" name: {}", plugin.name); + eprintln!(" version: {}", plugin.version); + if let (Some(owner), Some(repo)) = (&source.owner, &source.repo) { + let subdir = source + .subdir + .as_deref() + .map(|subdir| format!("/{subdir}")) + .unwrap_or_default(); + eprintln!(" source: {owner}/{repo}{subdir}"); + } + if let Some(reference) = &source.requested_ref { + eprintln!(" ref: {reference}"); + } + if let Some(commit) = &source.resolved_commit { + eprintln!(" commit: {commit}"); + } + eprintln!(" actions: {}", plugin.actions.len()); + eprintln!(" events: {}", plugin.events.len()); + eprintln!(" panes: {}", plugin.panes.len()); + eprintln!(" link handlers: {}", plugin.link_handlers.len()); + eprintln!(" build commands: {}", plugin.build.len()); + for build in &plugin.build { + let support = if build_platform_supported(&build.platforms, &plugin.platforms) { + String::new() + } else { + format!( + " (skipped on {})", + plugin_platform_name(current_plugin_platform()) + ) + }; + eprintln!(" build{}: {}", support, build.command.join(" ")); + } + for action in &plugin.actions { + eprintln!(" action {}: {}", action.id, action.command.join(" ")); + } + for event in &plugin.events { + eprintln!(" event {}: {}", event.on, event.command.join(" ")); + } + for pane in &plugin.panes { + eprintln!(" pane {}: {}", pane.id, pane.command.join(" ")); + } + for warning in &plugin.warnings { + eprintln!(" warning: {warning}"); + } + if let Some(existing) = existing { + eprintln!( + " replaces: {} from {}", + existing.plugin_id, + source_display(existing) + ); + } +} + +fn run_plugin_build_commands( + plugin: &InstalledPluginInfo, + manifest_root: &Path, +) -> Result<(), Box<PluginBuildFailure>> { + let total = plugin.build.len(); + for (index, build) in plugin.build.iter().enumerate() { + if !build_platform_supported(&build.platforms, &plugin.platforms) { + continue; + } + run_plugin_build_command( + &plugin.plugin_id, + index + 1, + total, + manifest_root, + &build.command, + )?; + } + Ok(()) +} + +fn ensure_manifest_unchanged_after_build( + before: &InstalledPluginInfo, + after: &InstalledPluginInfo, +) -> io::Result<()> { + if before == after { + return Ok(()); + } + Err(io::Error::other( + "plugin build changed herdr-plugin.toml after install preview; aborting install", + )) +} + +fn run_plugin_build_command( + plugin_id: &str, + build_index: usize, + build_total: usize, + cwd: &Path, + command: &[String], +) -> Result<(), Box<PluginBuildFailure>> { + let context = plugin_build_context(plugin_id, build_index, build_total, cwd, command); + let Some(program) = command.first() else { + return Err(Box::new(PluginBuildFailure { + context, + kind: PluginBuildFailureKind::Start { + error: io::Error::new( + io::ErrorKind::InvalidInput, + "build command must not be empty", + ), + }, + })); + }; + let mut child = Command::new(program); + child + .args(command.iter().skip(1)) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + scrub_herdr_runtime_env(&mut child); + + let mut child = match child.spawn() { + Ok(child) => child, + Err(err) => { + return Err(Box::new(PluginBuildFailure { + context, + kind: PluginBuildFailureKind::Start { error: err }, + })); + } + }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = stdout.map(|stdout| { + std::thread::spawn(move || read_tail_capped_output(stdout, PLUGIN_BUILD_OUTPUT_MAX_BYTES)) + }); + let stderr_reader = stderr.map(|stderr| { + std::thread::spawn(move || read_tail_capped_output(stderr, PLUGIN_BUILD_OUTPUT_MAX_BYTES)) + }); + let status = child.wait().map_err(|error| { + Box::new(PluginBuildFailure { + context: context.clone(), + kind: PluginBuildFailureKind::Wait { error }, + }) + })?; + let stdout = stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(); + let stderr = stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(); + if status.success() { + return Ok(()); + } + Err(Box::new(PluginBuildFailure { + context, + kind: PluginBuildFailureKind::Exit { + status, + stdout, + stderr, + }, + })) +} + +fn plugin_build_context( + plugin_id: &str, + build_index: usize, + build_total: usize, + cwd: &Path, + command: &[String], +) -> PluginBuildContext { + PluginBuildContext { + plugin_id: plugin_id.to_string(), + build_index, + build_total, + cwd: cwd.display().to_string(), + command: command.to_vec(), + } +} + +#[derive(Debug, Default)] +struct CappedOutput { + text: String, + truncated: bool, +} + +#[derive(Debug, Clone)] +struct PluginBuildContext { + plugin_id: String, + build_index: usize, + build_total: usize, + cwd: String, + command: Vec<String>, +} + +#[derive(Debug)] +struct PluginBuildFailure { + context: PluginBuildContext, + kind: PluginBuildFailureKind, +} + +#[derive(Debug)] +enum PluginBuildFailureKind { + Start { + error: io::Error, + }, + Wait { + error: io::Error, + }, + Exit { + status: ExitStatus, + stdout: CappedOutput, + stderr: CappedOutput, + }, +} + +impl fmt::Display for PluginBuildFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "error: plugin build failed")?; + write_build_context(f, &self.context)?; + match &self.kind { + PluginBuildFailureKind::Start { error } => { + writeln!(f, " error: failed to start: {error}")?; + } + PluginBuildFailureKind::Wait { error } => { + writeln!(f, " error: failed to wait for command: {error}")?; + } + PluginBuildFailureKind::Exit { + status, + stdout, + stderr, + } => { + writeln!(f, " status: {status}")?; + write_output_section(f, "stderr", stderr)?; + write_output_section(f, "stdout", stdout)?; + } + } + writeln!(f)?; + write!(f, "Plugin was not installed.") + } +} + +fn write_build_context(f: &mut fmt::Formatter<'_>, context: &PluginBuildContext) -> fmt::Result { + writeln!(f, " plugin: {}", context.plugin_id)?; + writeln!( + f, + " build: {}/{}", + context.build_index, context.build_total + )?; + writeln!(f, " cwd: {}", context.cwd)?; + writeln!(f, " command: {}", context.command.join(" ")) +} + +fn write_output_section( + f: &mut fmt::Formatter<'_>, + label: &str, + output: &CappedOutput, +) -> fmt::Result { + let text = output.text.trim_end(); + if text.is_empty() { + return Ok(()); + } + writeln!(f)?; + if output.truncated { + writeln!( + f, + "{label}: showing last {PLUGIN_BUILD_OUTPUT_MAX_BYTES} bytes; earlier output omitted" + )?; + } else { + writeln!(f, "{label}:")?; + } + writeln!(f, "{text}") +} + +fn read_tail_capped_output(mut reader: impl Read, cap: usize) -> CappedOutput { + let mut out = Vec::new(); + let mut buf = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + out.extend_from_slice(&buf[..n]); + if out.len() > cap { + let excess = out.len() - cap; + out.drain(0..excess); + truncated = true; + } + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + CappedOutput { + text: String::from_utf8_lossy(&out).to_string(), + truncated, + } +} + +fn scrub_herdr_runtime_env(command: &mut Command) { + for key in [ + crate::api::SOCKET_PATH_ENV_VAR, + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, + crate::session::SESSION_ENV_VAR, + "HERDR_BIN_PATH", + "HERDR_ENV", + "HERDR_WORKSPACE_ID", + "HERDR_TAB_ID", + "HERDR_PANE_ID", + ] { + command.env_remove(key); + } + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("HERDR_PLUGIN_") { + command.env_remove(key); + } + } +} + +fn build_platform_supported( + platforms: &Option<Vec<PluginPlatform>>, + plugin_platforms: &Option<Vec<PluginPlatform>>, +) -> bool { + platforms + .as_ref() + .or(plugin_platforms.as_ref()) + .is_none_or(|platforms| platforms.contains(¤t_plugin_platform())) +} + +fn current_plugin_platform() -> PluginPlatform { + if cfg!(target_os = "linux") { + PluginPlatform::Linux + } else if cfg!(target_os = "macos") { + PluginPlatform::Macos + } else { + PluginPlatform::Windows + } +} + +fn plugin_platform_name(platform: PluginPlatform) -> &'static str { + match platform { + PluginPlatform::Linux => "linux", + PluginPlatform::Macos => "macos", + PluginPlatform::Windows => "windows", + } +} + +fn confirm(prompt: &str) -> std::io::Result<bool> { + eprint!("{prompt} [y/N] "); + io::stderr().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + Ok(matches!(line.trim(), "y" | "Y" | "yes" | "YES" | "Yes")) +} + +fn create_plugin_temp_dir(label: &str) -> std::io::Result<PathBuf> { + let path = managed_plugins_dir().join(format!( + ".tmp-{label}-{}-{}", + std::process::id(), + current_unix_ms() + )); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +fn managed_plugins_dir() -> PathBuf { + crate::session::data_dir().join("plugins") +} + +fn managed_checkout_path(plugin_id: &str) -> PathBuf { + managed_plugins_dir() + .join("github") + .join(crate::api::schema::plugin_managed_path_component(plugin_id)) +} + +fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result<()> { + if plugin.source.kind != PluginSourceKind::Github { + return Ok(()); + } + let Some(path) = plugin.source.managed_path.as_deref() else { + return Ok(()); + }; + let path = PathBuf::from(path); + if !path.exists() { + return Ok(()); + } + if !is_expected_managed_path(plugin, &path) { + return Err(std::io::Error::other(format!( + "refusing to delete unmanaged plugin path: {}", + path.display() + ))); + } + std::fs::remove_dir_all(path) +} + +fn is_expected_managed_path(plugin: &InstalledPluginInfo, path: &Path) -> bool { + let Ok(path) = path.canonicalize() else { + return false; + }; + let expected = managed_checkout_path(&plugin.plugin_id); + let Ok(expected) = expected.canonicalize() else { + return false; + }; + path == expected +} + +fn current_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn is_connection_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + ) +} + +fn print_plugin_response(method: Method) -> std::io::Result<i32> { + super::print_response(&super::send_request(&Request { + id: "cli:plugin".into(), + method, + })?) +} + +fn print_plugin_help() { + eprintln!("herdr plugin commands:"); + eprintln!(" herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]"); + eprintln!(" herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>"); + eprintln!(" herdr plugin link <path> [--disabled]"); + eprintln!(" herdr plugin list [--plugin ID] [--json]"); + eprintln!(" herdr plugin unlink <plugin_id>"); + eprintln!(" herdr plugin enable <plugin_id>"); + eprintln!(" herdr plugin disable <plugin_id>"); + eprintln!(" herdr plugin action <list|invoke>"); + eprintln!(" herdr plugin log list [--plugin ID] [--limit N]"); + eprintln!(" herdr plugin pane <open|focus|close>"); +} + +fn print_plugin_action_help() { + eprintln!("herdr plugin action commands:"); + eprintln!(" herdr plugin action list [--plugin ID]"); + eprintln!(" herdr plugin action invoke <action_id> [--plugin ID]"); +} + +fn print_plugin_pane_help() { + eprintln!("herdr plugin pane commands:"); + eprintln!(" herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|split|tab|zoomed] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus]"); + eprintln!(" herdr plugin pane focus <pane_id>"); + eprintln!(" herdr plugin pane close <pane_id>"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn github_plugin( + id: &str, + owner: &str, + repo: &str, + subdir: Option<&str>, + ) -> InstalledPluginInfo { + InstalledPluginInfo { + plugin_id: id.to_string(), + name: "Test Plugin".to_string(), + version: "0.1.0".to_string(), + description: None, + manifest_path: format!("/tmp/{id}/herdr-plugin.toml"), + plugin_root: format!("/tmp/{id}"), + enabled: true, + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: PluginSourceInfo { + kind: PluginSourceKind::Github, + owner: Some(owner.to_string()), + repo: Some(repo.to_string()), + subdir: subdir.map(str::to_string), + requested_ref: None, + resolved_commit: Some("abc123".to_string()), + managed_path: Some(format!("/tmp/herdr/plugins/{id}")), + installed_unix_ms: Some(42), + }, + warnings: vec![], + } + } + + #[test] + fn github_plugin_source_parses_root_repo() { + let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); + assert_eq!(source.owner, "ogulcancelik"); + assert_eq!(source.repo, "herdr-plugin-examples"); + assert_eq!(source.subdir, None); + assert_eq!( + source.remote_url(), + "https://github.com/ogulcancelik/herdr-plugin-examples.git" + ); + } + + #[test] + fn github_plugin_source_parses_subdir() { + let source = + GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples/worktree-bootstrap") + .unwrap(); + assert_eq!(source.owner, "ogulcancelik"); + assert_eq!(source.repo, "herdr-plugin-examples"); + assert_eq!(source.subdir.as_deref(), Some("worktree-bootstrap")); + } + + #[test] + fn github_plugin_source_rejects_non_shorthand_sources() { + for source in [ + "https://github.com/ogulcancelik/herdr-plugin-examples", + "git@github.com:ogulcancelik/herdr-plugin-examples.git", + "ogulcancelik", + "ogulcancelik/herdr-plugin-examples/../bad", + "ogulcancelik/herdr-plugin-examples//bad", + ] { + assert!( + GithubPluginSource::parse(source).is_err(), + "{source} should be rejected" + ); + } + } + + #[test] + fn github_source_lookup_matches_installed_plugin_source() { + let source = + GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples/agent-telegram-notify") + .unwrap(); + let plugins = vec![ + github_plugin( + "examples.github-link-preview", + "ogulcancelik", + "herdr-plugin-examples", + Some("github-link-preview"), + ), + github_plugin( + "examples.agent-telegram-notify", + "ogulcancelik", + "herdr-plugin-examples", + Some("agent-telegram-notify"), + ), + ]; + + let plugin = plugin_by_github_source(plugins, &source).unwrap(); + assert_eq!(plugin.plugin_id, "examples.agent-telegram-notify"); + } + + #[test] + fn github_source_lookup_requires_exact_subdir() { + let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); + let plugins = vec![github_plugin( + "examples.agent-telegram-notify", + "ogulcancelik", + "herdr-plugin-examples", + Some("agent-telegram-notify"), + )]; + + assert!(plugin_by_github_source(plugins, &source).is_none()); + } + + #[test] + fn github_source_lookup_ignores_local_plugins() { + let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); + let mut plugin = github_plugin( + "examples.local", + "ogulcancelik", + "herdr-plugin-examples", + None, + ); + plugin.source = PluginSourceInfo::default(); + + assert!(plugin_by_github_source([plugin], &source).is_none()); + } +} diff --git a/src/cli/tab.rs b/src/cli/tab.rs index 3fcc0647..955a1ba1 100644 --- a/src/cli/tab.rs +++ b/src/cli/tab.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::api::schema::{ Method, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget, }; @@ -58,6 +60,7 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> { let mut cwd = None; let mut focus = false; let mut label = None; + let mut env = HashMap::new(); let mut index = 0; while index < args.len() { @@ -94,6 +97,21 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } + "--env" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --env"); + return Ok(2); + }; + let (key, value) = match super::parse_env_assignment(value) { + Ok(pair) => pair, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + env.insert(key, value); + index += 2; + } other => { eprintln!("unknown option: {other}"); return Ok(2); @@ -108,6 +126,7 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> { cwd, focus, label, + env, }), })?) } @@ -185,7 +204,7 @@ fn print_tab_help() { eprintln!("herdr tab commands:"); eprintln!(" herdr tab list [--workspace <workspace_id>]"); eprintln!( - " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]" + " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]" ); eprintln!(" herdr tab get <tab_id>"); eprintln!(" herdr tab focus <tab_id>"); diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index ae29792c..8149d55d 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::api::schema::{ EmptyParams, Method, Request, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, }; @@ -42,6 +44,7 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> { let mut cwd = None; let mut focus = false; let mut label = None; + let mut env = HashMap::new(); let mut index = 0; while index < args.len() { @@ -70,6 +73,21 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } + "--env" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --env"); + return Ok(2); + }; + let (key, value) = match super::parse_env_assignment(value) { + Ok(pair) => pair, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + env.insert(key, value); + index += 2; + } other => { eprintln!("unknown option: {other}"); return Ok(2); @@ -79,7 +97,12 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> { super::print_response(&super::send_request(&Request { id: "cli:workspace:create".into(), - method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }), + method: Method::WorkspaceCreate(WorkspaceCreateParams { + cwd, + focus, + label, + env, + }), })?) } @@ -155,7 +178,7 @@ fn workspace_close(args: &[String]) -> std::io::Result<i32> { fn print_workspace_help() { eprintln!("herdr workspace commands:"); eprintln!(" herdr workspace list"); - eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"); + eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]"); eprintln!(" herdr workspace get <workspace_id>"); eprintln!(" herdr workspace focus <workspace_id>"); eprintln!(" herdr workspace rename <workspace_id> <label>"); diff --git a/src/client/mod.rs b/src/client/mod.rs index e4daf82d..b7fd212d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -984,6 +984,10 @@ async fn run_client_loop( forward_clipboard(&data); let _ = io::stdout().flush(); } + ServerMessage::WindowTitle { title } => { + write_window_title(title.as_deref()); + let _ = io::stdout().flush(); + } ServerMessage::ReloadSoundConfig => { reload_local_client_config( &mut state.sound_config, @@ -1208,6 +1212,19 @@ fn forward_clipboard(data: &str) { crate::selection::write_osc52_bytes(&bytes); } +fn window_title_osc(title: Option<&str>) -> Vec<u8> { + let title = title.unwrap_or("herdr"); + let safe_title = title + .chars() + .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}')) + .collect::<String>(); + format!("\x1b]0;{safe_title}\x07").into_bytes() +} + +fn write_window_title(title: Option<&str>) { + let _ = io::stdout().write_all(&window_title_osc(title)); +} + // --------------------------------------------------------------------------- // Frame output // --------------------------------------------------------------------------- @@ -1905,4 +1922,13 @@ mod tests { std::env::remove_var("SSH_CONNECTION"); } } + + #[test] + fn window_title_osc_strips_terminators_and_defaults_to_herdr() { + assert_eq!( + window_title_osc(Some("herdr\x1b api\u{7}\u{9c}")), + b"\x1b]0;herdr api\x07" + ); + assert_eq!(window_title_osc(None), b"\x1b]0;herdr\x07"); + } } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index cc054995..92f13bf8 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -51,6 +51,7 @@ pub enum CommandKeybindType { #[default] Shell, Pane, + PluginAction, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -82,6 +83,7 @@ impl Default for CommandKeybindConfig { pub enum CustomCommandAction { Shell, Pane, + PluginAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -541,6 +543,7 @@ impl Config { let action = match command.action_type { CommandKeybindType::Shell => CustomCommandAction::Shell, CommandKeybindType::Pane => CustomCommandAction::Pane, + CommandKeybindType::PluginAction => CustomCommandAction::PluginAction, }; let label = bindings.label().unwrap_or_else(|| "unset".to_string()); keybinds.custom_commands.push(CustomCommandKeybind { diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index fd2ef15b..8811777b 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -156,7 +156,7 @@ line_regex = ["^exact line$"] #[test] fn remote_manifest_loads_between_local_override_and_bundled() { with_manifest_dirs("remote-source", || { - write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready")); let explain = explain(Agent::Codex, "remote-ready"); @@ -165,10 +165,10 @@ fn remote_manifest_loads_between_local_override_and_bundled() { explain.source, Some(ManifestSource::Remote { .. }) )); - assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3")); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.5")); assert_eq!( explain.cached_remote_version.as_deref(), - Some("2026.06.10.3") + Some("2026.06.10.5") ); }); } @@ -176,7 +176,7 @@ fn remote_manifest_loads_between_local_override_and_bundled() { #[test] fn fallback_explain_preserves_active_manifest_version() { with_manifest_dirs("fallback-version", || { - write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready")); let explain = explain(Agent::Codex, "ordinary prompt text"); @@ -185,7 +185,7 @@ fn fallback_explain_preserves_active_manifest_version() { explain.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); - assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3")); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.5")); assert!(matches!( explain.source, Some(ManifestSource::Remote { .. }) @@ -216,7 +216,7 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { #[test] fn local_override_shadows_cached_remote_manifest() { with_manifest_dirs("local-shadows-remote", || { - write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready")); write_local_codex(&local_manifest("idle", "local-ready")); let explain = explain(Agent::Codex, "local-ready"); @@ -226,7 +226,7 @@ fn local_override_shadows_cached_remote_manifest() { assert!(explain.local_override_shadowing_remote); assert_eq!( explain.cached_remote_version.as_deref(), - Some("2026.06.10.3") + Some("2026.06.10.5") ); }); } @@ -234,7 +234,7 @@ fn local_override_shadows_cached_remote_manifest() { #[test] fn invalid_local_override_falls_back_to_cached_remote_manifest() { with_manifest_dirs("invalid-local-remote-fallback", || { - write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready")); write_local_codex("id = "); let explain = explain(Agent::Codex, "remote-ready"); @@ -251,7 +251,7 @@ fn invalid_local_override_falls_back_to_cached_remote_manifest() { #[test] fn detection_uses_cached_manifest_until_explicit_reload() { with_manifest_dirs("cache-boundary", || { - write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "cached-ready")); + write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "cached-ready")); let cached = explain(Agent::Codex, "cached-ready"); assert_eq!(cached.state, AgentState::Blocked); @@ -261,7 +261,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { Some("test") ); - write_remote_codex_without_reload(&remote_manifest("2026.06.10.4", "working", "new-ready")); + write_remote_codex_without_reload(&remote_manifest("2026.06.10.6", "working", "new-ready")); let unchanged = explain(Agent::Codex, "new-ready"); assert_eq!(unchanged.state, AgentState::Idle); @@ -271,7 +271,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { ); assert_eq!( unchanged.cached_remote_version.as_deref(), - Some("2026.06.10.3") + Some("2026.06.10.5") ); reload_manifests(); @@ -280,7 +280,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { assert_eq!(reloaded.state, AgentState::Working); assert_eq!( reloaded.cached_remote_version.as_deref(), - Some("2026.06.10.4") + Some("2026.06.10.6") ); assert_eq!( reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()), diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs index f82b4864..a1537fab 100644 --- a/src/detect/manifest_update.rs +++ b/src/detect/manifest_update.rs @@ -614,7 +614,7 @@ contains = ["{contains}"] #[test] fn process_agent_manifest_commits_newer_manifest_atomically() { with_state_dir("commit-newer", || { - let content = remote_manifest("2026.06.10.3", "ready"); + let content = remote_manifest("2026.06.10.5", "ready"); let commit = process_agent_manifest(Agent::Codex, &content, 1) .unwrap() .unwrap(); @@ -622,7 +622,7 @@ contains = ["{contains}"] assert_eq!(commit.agent, Agent::Codex); assert_eq!( commit.version, - ManifestVersion::parse("2026.06.10.3").unwrap() + ManifestVersion::parse("2026.06.10.5").unwrap() ); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -652,7 +652,7 @@ path = "codex.toml" .unwrap(); fs::write( web_dir.join("codex.toml"), - remote_manifest("2026.06.10.3", "auto-update-ready"), + remote_manifest("2026.06.10.5", "auto-update-ready"), ) .unwrap(); std::env::set_var( @@ -693,10 +693,10 @@ path = "codex.toml" #[test] fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() { with_state_dir("reject-downgrade", || { - let current = remote_manifest("2026.06.10.4", "current"); + let current = remote_manifest("2026.06.10.5", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - let older = remote_manifest("2026.06.10.3", "older"); + let older = remote_manifest("2026.06.10.4", "older"); assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err()); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -708,10 +708,10 @@ path = "codex.toml" #[test] fn process_agent_manifest_rejects_equal_version_content_change() { with_state_dir("reject-equal-change", || { - let current = remote_manifest("2026.06.10.3", "current"); + let current = remote_manifest("2026.06.10.5", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - let changed = remote_manifest("2026.06.10.3", "changed"); + let changed = remote_manifest("2026.06.10.5", "changed"); assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err()); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -723,7 +723,7 @@ path = "codex.toml" #[test] fn process_agent_manifest_skips_same_version_same_content() { with_state_dir("skip-same", || { - let current = remote_manifest("2026.06.10.3", "current"); + let current = remote_manifest("2026.06.10.5", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); let result = process_agent_manifest(Agent::Codex, ¤t, 2).unwrap(); diff --git a/src/events.rs b/src/events.rs index 07660c71..6185b8a7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -111,6 +111,15 @@ pub enum AppEvent { results: Vec<WorkspaceGitStatus>, cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, }, + /// A plugin action or event command finished. + PluginCommandFinished { + log_id: String, + finished_unix_ms: u64, + exit_code: Option<i32>, + stdout: String, + stderr: String, + error: Option<String>, + }, /// Background `git worktree add` completed. WorktreeAddFinished(WorktreeAddResult), /// Background `git worktree remove` completed. diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 5d6b8e7e..d483e503 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -7,9 +7,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; use portable_pty::CommandBuilder; use serde_json::{json, Map, Value}; -use crate::layout::PaneId; - pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID"; +pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID"; +pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID"; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); const PI_INTEGRATION_VERSION: u32 = 2; @@ -360,18 +360,8 @@ pub(crate) struct HermesUninstallResult { pub updated_config: bool, } -pub(crate) fn apply_pane_env( - cmd: &mut CommandBuilder, - pane_id: PaneId, - public_pane_id: Option<&str>, -) { +pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); - cmd.env( - HERDR_PANE_ID_ENV_VAR, - public_pane_id - .map(str::to_string) - .unwrap_or_else(|| format!("p_{}", pane_id.raw())), - ); } pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/pane.rs b/src/pane.rs index 601334d9..7cb3e12b 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -59,6 +59,58 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { cmd.env("COLORTERM", PANE_COLORTERM); } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct PaneLaunchEnv { + extra: Vec<(String, String)>, + identity: Option<PaneLaunchIdentity>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PaneLaunchIdentity { + workspace_id: String, + tab_id: String, + pane_id: String, +} + +impl PaneLaunchEnv { + pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self { + Self { + extra, + identity: None, + } + } + + pub(crate) fn with_identity( + mut self, + workspace_id: String, + tab_id: String, + pane_id: String, + ) -> Self { + self.identity = Some(PaneLaunchIdentity { + workspace_id, + tab_id, + pane_id, + }); + self + } +} + +fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { + for (key, value) in &launch_env.extra { + cmd.env(key, value); + } + cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); + crate::integration::apply_pane_base_env(cmd); + if let Some(identity) = &launch_env.identity { + cmd.env( + crate::integration::HERDR_WORKSPACE_ID_ENV_VAR, + &identity.workspace_id, + ); + cmd.env(crate::integration::HERDR_TAB_ID_ENV_VAR, &identity.tab_id); + cmd.env(crate::integration::HERDR_PANE_ID_ENV_VAR, &identity.pane_id); + } +} + #[derive(Debug, Clone, Copy)] struct PendingAgentRelease { agent: Agent, @@ -1320,10 +1372,10 @@ impl PaneRuntime { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { Self::spawn_with_initial_history( pane_id, @@ -1333,11 +1385,11 @@ impl PaneRuntime { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, None, events, render_notify, render_dirty, - public_pane_id, ) } @@ -1351,17 +1403,16 @@ impl PaneRuntime { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); apply_pane_terminal_env(&mut cmd); - crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id); + apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, @@ -1388,24 +1439,19 @@ impl PaneRuntime { cols: u16, cwd: std::path::PathBuf, command: &str, - extra_env: &[(String, String)], + launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { let mut cmd = CommandBuilder::new("/bin/sh"); cmd.arg("-c"); cmd.arg(command); cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); apply_pane_terminal_env(&mut cmd); - crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id); - for (key, value) in extra_env { - cmd.env(key, value); - } + apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, @@ -1427,12 +1473,12 @@ impl PaneRuntime { cols: u16, cwd: std::path::PathBuf, argv: &[String], + launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { let Some((program, args)) = argv.split_first() else { return Err(std::io::Error::new( @@ -1445,9 +1491,8 @@ impl PaneRuntime { cmd.arg(arg); } cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); apply_pane_terminal_env(&mut cmd); - crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id); + apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, @@ -2425,6 +2470,11 @@ impl PaneRuntime { crate::platform::process_cwd(pid) } + pub fn child_pid(&self) -> Option<u32> { + let pid = self.child_pid.load(Ordering::Acquire); + (pid > 0).then_some(pid) + } + /// Get the current working directory of the process group controlling the pane PTY. pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> { #[cfg(unix)] diff --git a/src/persist.rs b/src/persist.rs index aff76bfe..822af263 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -2,8 +2,10 @@ //! //! Stored at `~/.config/herdr/session.json`. //! Optional pane screen history is stored separately at `session-history.json`. +//! Installed plugins are persisted separately at `plugins.json`. mod io; +pub mod plugin_registry; mod restore; mod snapshot; diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs new file mode 100644 index 00000000..19db842a --- /dev/null +++ b/src/persist/plugin_registry.rs @@ -0,0 +1,237 @@ +use std::path::{Path, PathBuf}; + +use tracing::warn; + +use crate::api::schema::InstalledPluginInfo; + +pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: "; + +fn registry_path() -> PathBuf { + crate::session::data_dir().join("plugins.json") +} + +fn save_json_to_path<T: serde::Serialize + ?Sized>(path: &Path, value: &T) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(value)?; + let tmp_path = path.with_extension("json.tmp"); + std::fs::write(&tmp_path, &json)?; + if let Err(err) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err); + } + Ok(()) +} + +/// Atomically write `plugins.json` next to `session.json`. +pub fn save(plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + let path = registry_path(); + save_to_path(&path, plugins) +} + +pub fn save_to_path(path: &Path, plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + save_json_to_path(path, plugins) +} + +/// Load `plugins.json`. Returns an empty vec on any failure so a corrupt or +/// missing file never blocks server startup. +pub fn load() -> Vec<InstalledPluginInfo> { + load_from_path(®istry_path()) +} + +pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> { + if !path.exists() { + return Vec::new(); + } + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to read plugin registry"); + return Vec::new(); + } + }; + match serde_json::from_str::<Vec<InstalledPluginInfo>>(&content) { + Ok(entries) => entries, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry"); + Vec::new() + } + } +} + +/// Re-read each entry's manifest from disk using the provided reload function. +/// +/// If the manifest parses successfully, replace cached fields but keep the +/// stored `enabled` flag. If the file is gone or unparseable, keep the stored +/// entry and append a warning so `plugin.list` surfaces it. +pub fn reload_manifests( + mut entries: Vec<InstalledPluginInfo>, + reload_fn: impl Fn(&str, bool) -> Result<InstalledPluginInfo, String>, +) -> Vec<InstalledPluginInfo> { + for entry in &mut entries { + entry.warnings.clear(); + match reload_fn(&entry.manifest_path, entry.enabled) { + Ok(mut fresh) => { + fresh.enabled = entry.enabled; + fresh.source = entry.source.clone(); + *entry = fresh; + } + Err(warn_msg) => { + entry + .warnings + .push(format!("{MANIFEST_UNAVAILABLE_WARNING_PREFIX}{warn_msg}")); + } + } + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_registry_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir() + .join(format!( + "herdr-registry-{name}-{}-{nanos}", + std::process::id() + )) + .join("plugins.json") + } + + fn sample_plugin(id: &str) -> InstalledPluginInfo { + InstalledPluginInfo { + plugin_id: id.to_string(), + name: "Test Plugin".to_string(), + version: "0.1.0".to_string(), + description: None, + manifest_path: format!("/tmp/{id}/herdr-plugin.toml"), + plugin_root: format!("/tmp/{id}"), + enabled: true, + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + } + } + + #[test] + fn save_and_load_roundtrip() { + let path = temp_registry_path("roundtrip"); + let plugins = vec![sample_plugin("example.a"), sample_plugin("example.b")]; + + save_to_path(&path, &plugins).unwrap(); + + let loaded = load_from_path(&path); + assert_eq!(loaded.len(), 2); + let ids: Vec<_> = loaded.iter().map(|p| p.plugin_id.as_str()).collect(); + assert!(ids.contains(&"example.a")); + assert!(ids.contains(&"example.b")); + } + + #[test] + fn missing_file_returns_empty() { + let path = temp_registry_path("missing"); + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn corrupt_file_returns_empty_without_panic() { + let path = temp_registry_path("corrupt"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, b"this is not valid json {{{{").unwrap(); + + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn reload_manifests_keeps_entry_with_warning_on_missing_manifest() { + let entry = sample_plugin("example.missing"); + let entries = vec![entry]; + + let result = reload_manifests(entries, |path, _enabled| { + Err(format!("manifest not found at {path}")) + }); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].plugin_id, "example.missing"); + assert!(!result[0].warnings.is_empty()); + assert!(result[0].warnings[0].contains("manifest not found")); + } + + #[test] + fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() { + let mut entry = sample_plugin("example.reload"); + entry.enabled = false; + entry.source = crate::api::schema::PluginSourceInfo { + kind: crate::api::schema::PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: Some("main".into()), + resolved_commit: Some("abc123".into()), + managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()), + installed_unix_ms: Some(42), + }; + + let result = reload_manifests(vec![entry], |_path, _enabled| { + Ok(InstalledPluginInfo { + plugin_id: "example.reload".to_string(), + name: "Fresh Name".to_string(), + version: "0.2.0".to_string(), + description: Some("refreshed".to_string()), + manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(), + plugin_root: "/tmp/example.reload".to_string(), + enabled: true, // caller would pass stored enabled; fresh parse returns true + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + }) + }); + + assert_eq!(result[0].name, "Fresh Name"); + assert_eq!(result[0].version, "0.2.0"); + // enabled preserved from stored entry + assert!(!result[0].enabled); + assert_eq!( + result[0].source.kind, + crate::api::schema::PluginSourceKind::Github + ); + assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik")); + assert!(result[0].warnings.is_empty()); + } + + #[test] + fn atomic_write_temp_file_is_cleaned_up_on_rename_failure() { + // Write to a path whose parent does not yet exist, then verify the + // tmp file is removed when the write fails mid-way. Here we just + // confirm a successful write leaves no .tmp file behind. + let path = temp_registry_path("cleanup"); + save_to_path(&path, &[sample_plugin("example.cleanup")]).unwrap(); + + let tmp = path.with_extension("json.tmp"); + assert!( + !tmp.exists(), + "tmp file should be cleaned up after successful rename" + ); + assert!(path.exists()); + } +} diff --git a/src/persist/restore.rs b/src/persist/restore.rs index ca1748e0..229d565d 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -10,7 +10,7 @@ use tracing::{error, warn}; use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; -use crate::pane::PaneState; +use crate::pane::{PaneLaunchEnv, PaneState}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; @@ -354,10 +354,12 @@ fn restore_workspace( let mut failed_imports = 0; for (idx, tab_snap) in snap.tabs.iter().enumerate() { + let tab_number = snap.public_tab_numbers.get(idx).copied().unwrap_or(idx + 1); let (restored_tab, tab_failed_imports) = restore_tab( tab_snap, history.and_then(|history| history.tabs.get(idx)), - idx + 1, + tab_number, + &workspace_id, rows, cols, runtime_context, @@ -438,6 +440,7 @@ fn restore_tab( snap: &TabSnapshot, history: Option<&TabHistorySnapshot>, number: usize, + workspace_id: &str, rows: u16, cols: u16, runtime_context: &RestoreRuntimeContext<'_>, @@ -502,6 +505,19 @@ fn restore_tab( let public_pane_id = old_pane_id .and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id)) .map(String::as_str); + let launch_env = public_pane_id + .map(|pane_id| { + PaneLaunchEnv::from_extra(Vec::new()).with_identity( + workspace_id.to_string(), + format!( + "{}:t{}", + workspace_id, + crate::workspace::encode_public_number(number) + ), + pane_id.to_string(), + ) + }) + .unwrap_or_default(); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); let pending_native_agent_restore = if was_imported { @@ -570,11 +586,11 @@ fn restore_tab( runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), runtime_context.shell_config, + &launch_env, startup.initial_history_ansi, runtime_context.events.clone(), runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), - public_pane_id, ) } @@ -588,11 +604,11 @@ fn restore_tab( runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), runtime_context.shell_config, + &launch_env, startup.initial_history_ansi, runtime_context.events.clone(), runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), - public_pane_id, ) } }; diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 03492547..79c051b7 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -628,6 +628,12 @@ pub enum ServerMessage { data: String, }, + /// Set the foreground client's outer terminal window title. + WindowTitle { + /// Sanitized title to write with OSC 0. `None` restores Herdr's default title. + title: Option<String>, + }, + /// Client-local runtime config changed on disk; refresh it without reconnecting. ReloadSoundConfig, @@ -929,7 +935,7 @@ mod tests { } #[test] - fn client_message_wire_tags_preserve_protocol_13_order() { + fn client_message_wire_tags_preserve_protocol_14_order() { fn tag(msg: &ClientMessage) -> u8 { *bincode::serde::encode_to_vec(msg, bincode::config::standard()) .unwrap() @@ -1279,6 +1285,17 @@ mod tests { } #[test] + fn server_window_title_roundtrip() { + for title in [Some("herdr api".to_owned()), None] { + let msg = ServerMessage::WindowTitle { title }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + } + + #[test] fn server_graphics_roundtrip() { let msg = ServerMessage::Graphics { bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(), diff --git a/src/server/headless.rs b/src/server/headless.rs index ed81b015..750e9250 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -100,7 +100,7 @@ fn non_empty_body(value: &str) -> Option<String> { enum LoopEvent { Timer, Internal(AppEvent), - Api(api::ApiRequestMessage), + Api(Box<api::ApiRequestMessage>), ServerEvent(ServerEvent), RenderRequested, } @@ -637,7 +637,7 @@ impl HeadlessServer { let event = { tokio::select! { maybe_api = self.app.api_rx.recv() => match maybe_api { - Some(msg) => LoopEvent::Api(msg), + Some(msg) => LoopEvent::Api(Box::new(msg)), None => LoopEvent::Timer, }, maybe_ev = self.app.event_rx.recv() => match maybe_ev { @@ -662,7 +662,7 @@ impl HeadlessServer { } } LoopEvent::Api(msg) => { - if self.handle_api_request_with_shutdown_check(msg) { + if self.handle_api_request_with_shutdown_check(*msg) { needs_render = true; needs_full_render = true; } @@ -1603,6 +1603,39 @@ impl HeadlessServer { .unwrap_or_else(|_| "{}".to_string()) } + fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String { + use api::schema::{ClientWindowTitleReason, ResponseResult}; + + let title = match title { + Some(title) => match sanitize_window_title_text(&title, 200) { + Some(title) => Some(title), + None => { + return serde_json::to_string(&api::schema::ErrorResponse { + id, + error: api::schema::ErrorBody { + code: "invalid_params".into(), + message: "window title is empty".into(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()); + } + }, + None => None, + }; + let set_title = title.is_some(); + let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title }); + let reason = match (changed, set_title) { + (true, true) => ClientWindowTitleReason::Set, + (true, false) => ClientWindowTitleReason::Cleared, + (false, _) => ClientWindowTitleReason::NoForegroundClient, + }; + serde_json::to_string(&api::schema::SuccessResponse { + id, + result: ResponseResult::ClientWindowTitle { changed, reason }, + }) + .unwrap_or_else(|_| "{}".to_string()) + } + fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) { let Some(sound) = sound.to_sound() else { return; @@ -2511,6 +2544,23 @@ impl HeadlessServer { return true; } + match &msg.request.method { + api::schema::Method::ClientWindowTitleSet(params) => { + let response = self.handle_client_window_title_api( + msg.request.id.clone(), + Some(params.title.clone()), + ); + let _ = msg.respond_to.send(response); + return true; + } + api::schema::Method::ClientWindowTitleClear(_) => { + let response = self.handle_client_window_title_api(msg.request.id.clone(), None); + let _ = msg.respond_to.send(response); + return true; + } + _ => {} + } + let mut changed = api::request_changes_ui(&msg.request); let skip_default_workspace = matches!( &msg.request.method, @@ -3544,6 +3594,17 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> { (!sanitized.is_empty()).then_some(sanitized) } +fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> { + let sanitized = value + .chars() + .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control()) + .take(max_chars) + .collect::<String>() + .trim() + .to_string(); + (!sanitized.is_empty()).then_some(sanitized) +} + fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) { let without_keybindings = diagnostics .iter() diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 63bfe944..79ff63ca 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -121,10 +121,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index c4bf91e1..5e4f778e 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -85,10 +85,10 @@ impl TerminalRuntime { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &crate::pane::PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn( pane_id, @@ -98,10 +98,10 @@ impl TerminalRuntime { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, events, render_notify, render_dirty, - public_pane_id, ) .map(Self) } @@ -116,11 +116,11 @@ impl TerminalRuntime { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &crate::pane::PaneLaunchEnv, initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_with_initial_history( pane_id, @@ -130,11 +130,11 @@ impl TerminalRuntime { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, initial_history_ansi, events, render_notify, render_dirty, - public_pane_id, ) .map(Self) } @@ -147,13 +147,12 @@ impl TerminalRuntime { cols: u16, cwd: std::path::PathBuf, command: &str, - extra_env: &[(String, String)], + launch_env: &crate::pane::PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_shell_command( pane_id, @@ -161,13 +160,12 @@ impl TerminalRuntime { cols, cwd, command, - extra_env, + launch_env, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, - public_pane_id, ) .map(Self) } @@ -178,12 +176,12 @@ impl TerminalRuntime { cols: u16, cwd: std::path::PathBuf, argv: &[String], + launch_env: &crate::pane::PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<Self> { crate::pane::PaneRuntime::spawn_argv_command( pane_id, @@ -191,12 +189,12 @@ impl TerminalRuntime { cols, cwd, argv, + launch_env, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, - public_pane_id, ) .map(Self) } @@ -412,6 +410,10 @@ impl TerminalRuntime { self.0.foreground_cwd() } + pub fn child_pid(&self) -> Option<u32> { + self.0.child_pid() + } + pub(crate) fn current_size(&self) -> (u16, u16) { self.0.current_size() } diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 33fd4443..b8f23073 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -1007,10 +1007,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 293eeb24..0704f4a2 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1330,10 +1330,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); diff --git a/src/workspace.rs b/src/workspace.rs index b734008f..c4c91785 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -11,7 +11,7 @@ use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; -use crate::pane::PaneState; +use crate::pane::{PaneLaunchEnv, PaneState}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; mod aggregate; @@ -26,7 +26,7 @@ pub use self::{ derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key, GitSpaceMetadata, GitStatusCacheEntry, }, - tab::Tab, + tab::{NewPane, Tab}, }; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -113,6 +113,10 @@ fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String { format!("{workspace_id}:p{}", encode_public_number(pane_number)) } +fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> String { + format!("{workspace_id}:t{}", encode_public_number(tab_number)) +} + pub(crate) fn reserve_workspace_ids(workspaces: &[Workspace]) { let Some(next) = workspaces .iter() @@ -222,6 +226,9 @@ impl Workspace { } } + // Test modules construct workspaces through the default constructor; production paths + // use the env-aware variant so pane identity env is always explicit. + #[cfg_attr(not(test), allow(dead_code))] pub fn new( initial_cwd: PathBuf, rows: u16, @@ -233,6 +240,33 @@ impl Workspace { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + Self::new_with_extra_env( + initial_cwd, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + shell_config, + events, + render_notify, + render_dirty, + Vec::new(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_extra_env( + initial_cwd: PathBuf, + rows: u16, + cols: u16, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + shell_config: crate::pane::PaneShellConfig<'_>, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + extra_env: Vec<(String, String)>, + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, @@ -244,9 +278,12 @@ impl Workspace { render_notify, render_dirty, None, + extra_env, ) } + // Kept for tests that do not need launch-env customization. + #[allow(dead_code)] pub fn new_argv_command( initial_cwd: PathBuf, rows: u16, @@ -258,6 +295,33 @@ impl Workspace { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + Self::new_argv_command_with_extra_env( + initial_cwd, + rows, + cols, + argv, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + Vec::new(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_argv_command_with_extra_env( + initial_cwd: PathBuf, + rows: u16, + cols: u16, + argv: &[String], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + extra_env: Vec<(String, String)>, + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, @@ -269,6 +333,7 @@ impl Workspace { render_notify, render_dirty, Some(argv), + extra_env, ) } @@ -284,9 +349,14 @@ impl Workspace { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, + extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let id = generate_workspace_id(); - let root_public_pane_id = public_pane_id_for_number(&id, 1); + let launch_env = PaneLaunchEnv::from_extra(extra_env).with_identity( + id.clone(), + public_tab_id_for_number(&id, 1), + public_pane_id_for_number(&id, 1), + ); let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( 1, @@ -296,10 +366,10 @@ impl Workspace { argv, scrollback_limit_bytes, host_terminal_theme, + &launch_env, events, render_notify, render_dirty, - Some(&root_public_pane_id), )? } else { Tab::new( @@ -310,10 +380,10 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + &launch_env, events, render_notify, render_dirty, - Some(&root_public_pane_id), )? }; let mut public_pane_numbers = HashMap::new(); @@ -375,6 +445,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + extra_env: Vec<(String, String)>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, @@ -384,6 +455,29 @@ impl Workspace { host_terminal_theme, shell_config, None, + extra_env, + ) + } + + pub fn create_tab_argv_command( + &mut self, + rows: u16, + cols: u16, + cwd: PathBuf, + argv: &[String], + extra_env: Vec<(String, String)>, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { + self.create_tab_with_runtime( + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + Some(argv), + extra_env, ) } @@ -396,11 +490,12 @@ impl Workspace { host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, argv: Option<&[String]>, + extra_env: Vec<(String, String)>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { let number = self.next_public_tab_number; self.next_public_tab_number += 1; let pane_number = self.next_public_pane_number; - let public_pane_id = public_pane_id_for_number(&self.id, pane_number); + let launch_env = self.launch_env_for_new_pane(number, pane_number, extra_env); let events = self .active_tab() .map(|tab| tab.events.clone()) @@ -423,10 +518,10 @@ impl Workspace { argv, scrollback_limit_bytes, host_terminal_theme, + &launch_env, events, render_notify, render_dirty, - Some(&public_pane_id), )? } else { Tab::new( @@ -437,10 +532,10 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + &launch_env, events, render_notify, render_dirty, - Some(&public_pane_id), )? }; self.register_new_pane_with_number(tab.root_pane, pane_number); @@ -502,9 +597,14 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + extra_env: Vec<(String, String)>, ) -> std::io::Result<crate::workspace::tab::NewPane> { let pane_number = self.next_public_pane_number; - let public_pane_id = public_pane_id_for_number(&self.id, pane_number); + let tab_number = self + .active_tab() + .map(|tab| tab.number) + .expect("workspace must always have at least one tab"); + let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); let new_pane = self .active_tab_mut() .expect("workspace must always have at least one tab") @@ -516,7 +616,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, - Some(&public_pane_id), + &launch_env, )?; self.register_new_pane_with_number(new_pane.pane_id, pane_number); Ok(new_pane) @@ -530,12 +630,16 @@ impl Workspace { cols: u16, cwd: Option<PathBuf>, command: &str, - extra_env: &[(String, String)], + extra_env: Vec<(String, String)>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<crate::workspace::tab::NewPane> { let pane_number = self.next_public_pane_number; - let public_pane_id = public_pane_id_for_number(&self.id, pane_number); + let tab_number = self + .active_tab() + .map(|tab| tab.number) + .expect("workspace must always have at least one tab"); + let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); let new_pane = self .active_tab_mut() .expect("workspace must always have at least one tab") @@ -545,10 +649,9 @@ impl Workspace { cols, cwd, command, - extra_env, + &launch_env, scrollback_limit_bytes, host_terminal_theme, - Some(&public_pane_id), )?; self.register_new_pane_with_number(new_pane.pane_id, pane_number); Ok(new_pane) @@ -564,6 +667,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + extra_env: Vec<(String, String)>, focus_new_pane: bool, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { self.split_pane_with_runtime( @@ -576,6 +680,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + extra_env, focus_new_pane, None, ) @@ -593,6 +698,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + extra_env: Vec<(String, String)>, focus_new_pane: bool, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { self.split_pane_with_runtime( @@ -605,6 +711,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + extra_env, focus_new_pane, None, ) @@ -619,6 +726,7 @@ impl Workspace { cols: u16, cwd: Option<PathBuf>, argv: &[String], + extra_env: Vec<(String, String)>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, focus_new_pane: bool, @@ -633,6 +741,38 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + extra_env, + focus_new_pane, + Some(argv), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn split_pane_argv_command_with_ratio( + &mut self, + pane_id: PaneId, + direction: Direction, + ratio: f32, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + argv: &[String], + extra_env: Vec<(String, String)>, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + focus_new_pane: bool, + ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { + self.split_pane_with_runtime( + pane_id, + direction, + Some(ratio), + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + extra_env, focus_new_pane, Some(argv), ) @@ -650,26 +790,41 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + extra_env: Vec<(String, String)>, focus_new_pane: bool, argv: Option<&[String]>, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { let tab_idx = self.find_tab_index_for_pane(pane_id)?; let pane_number = self.next_public_pane_number; - let public_pane_id = public_pane_id_for_number(&self.id, pane_number); + let tab_number = self.tabs[tab_idx].number; + let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); let tab = &mut self.tabs[tab_idx]; let previous_focus = tab.layout.focused(); tab.layout.focus_pane(pane_id); let new_pane = match if let Some(argv) = argv { - tab.split_focused_argv_command( - direction, - rows, - cols, - cwd, - argv, - scrollback_limit_bytes, - host_terminal_theme, - Some(&public_pane_id), - ) + match ratio { + Some(ratio) => tab.split_focused_argv_command_with_ratio( + direction, + ratio, + rows, + cols, + cwd, + argv, + &launch_env, + scrollback_limit_bytes, + host_terminal_theme, + ), + None => tab.split_focused_argv_command( + direction, + rows, + cols, + cwd, + argv, + &launch_env, + scrollback_limit_bytes, + host_terminal_theme, + ), + } } else { match ratio { Some(ratio) => tab.split_focused_with_ratio( @@ -681,7 +836,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, - Some(&public_pane_id), + &launch_env, ), None => tab.split_focused( direction, @@ -691,7 +846,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, - Some(&public_pane_id), + &launch_env, ), } } { @@ -836,6 +991,19 @@ impl Workspace { self.public_pane_numbers.get(&pane_id).copied() } + fn launch_env_for_new_pane( + &self, + tab_number: usize, + pane_number: usize, + extra_env: Vec<(String, String)>, + ) -> PaneLaunchEnv { + PaneLaunchEnv::from_extra(extra_env).with_identity( + self.id.clone(), + public_tab_id_for_number(&self.id, tab_number), + public_pane_id_for_number(&self.id, pane_number), + ) + } + pub fn public_tab_number(&self, tab_idx: usize) -> Option<usize> { self.tabs.get(tab_idx).map(|tab| tab.number) } diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 0d9e6fdb..480bc329 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -8,7 +8,7 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; -use crate::pane::PaneState; +use crate::pane::{PaneLaunchEnv, PaneState}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; pub(crate) type DetachedPane = (PaneId, TerminalId); @@ -27,10 +27,11 @@ pub struct NewPane { enum SplitCommand<'a> { Shell { command: &'a str, - extra_env: &'a [(String, String)], + launch_env: &'a PaneLaunchEnv, }, Argv { argv: &'a [String], + launch_env: &'a PaneLaunchEnv, }, } @@ -59,10 +60,10 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, @@ -72,11 +73,11 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, events, render_notify, render_dirty, None, - public_pane_id, ) } @@ -88,10 +89,10 @@ impl Tab { argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, + launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - public_pane_id: Option<&str>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, @@ -101,11 +102,11 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + launch_env, events, render_notify, render_dirty, Some(argv), - public_pane_id, ) } @@ -118,11 +119,11 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, - public_pane_id: Option<&str>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (layout, root_id) = TileLayout::new(); let runtime = if let Some(argv) = argv { @@ -132,12 +133,12 @@ impl Tab { cols, initial_cwd.clone(), argv, + launch_env, scrollback_limit_bytes, host_terminal_theme, events.clone(), render_notify.clone(), render_dirty.clone(), - public_pane_id, )? } else { TerminalRuntime::spawn( @@ -148,10 +149,10 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, events.clone(), render_notify.clone(), render_dirty.clone(), - public_pane_id, )? }; @@ -207,7 +208,7 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, - public_pane_id: Option<&str>, + launch_env: &PaneLaunchEnv, ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, @@ -218,8 +219,8 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, None, - public_pane_id, ) } @@ -233,7 +234,7 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, - public_pane_id: Option<&str>, + launch_env: &PaneLaunchEnv, ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, @@ -244,8 +245,8 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, None, - public_pane_id, ) } @@ -256,10 +257,9 @@ impl Tab { cols: u16, cwd: Option<PathBuf>, command: &str, - extra_env: &[(String, String)], + launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - public_pane_id: Option<&str>, ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, @@ -270,8 +270,11 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), - Some(SplitCommand::Shell { command, extra_env }), - public_pane_id, + launch_env, + Some(SplitCommand::Shell { + command, + launch_env, + }), ) } @@ -282,9 +285,9 @@ impl Tab { cols: u16, cwd: Option<PathBuf>, argv: &[String], + launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - public_pane_id: Option<&str>, ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, @@ -295,8 +298,34 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), - Some(SplitCommand::Argv { argv }), - public_pane_id, + launch_env, + Some(SplitCommand::Argv { argv, launch_env }), + ) + } + + pub fn split_focused_argv_command_with_ratio( + &mut self, + direction: Direction, + ratio: f32, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + argv: &[String], + launch_env: &PaneLaunchEnv, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result<NewPane> { + self.split_focused_with_runtime( + direction, + Some(ratio), + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + launch_env, + Some(SplitCommand::Argv { argv, launch_env }), ) } @@ -310,8 +339,8 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, command: Option<SplitCommand<'_>>, - public_pane_id: Option<&str>, ) -> std::io::Result<NewPane> { let previous_focus = self.layout.focused(); let new_id = match ratio { @@ -320,40 +349,40 @@ impl Tab { }; let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command { + let launch_argv = if let Some(SplitCommand::Argv { argv, .. }) = &command { Some((*argv).to_vec()) } else { None }; let runtime = match command { - Some(SplitCommand::Shell { command, extra_env }) => { - TerminalRuntime::spawn_shell_command( - new_id, - rows, - cols, - actual_cwd.clone(), - command, - extra_env, - scrollback_limit_bytes, - host_terminal_theme, - self.events.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - public_pane_id, - ) - } - Some(SplitCommand::Argv { argv }) => TerminalRuntime::spawn_argv_command( + Some(SplitCommand::Shell { + command, + launch_env, + }) => TerminalRuntime::spawn_shell_command( + new_id, + rows, + cols, + actual_cwd.clone(), + command, + launch_env, + scrollback_limit_bytes, + host_terminal_theme, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ), + Some(SplitCommand::Argv { argv, launch_env }) => TerminalRuntime::spawn_argv_command( new_id, rows, cols, actual_cwd.clone(), argv, + launch_env, scrollback_limit_bytes, host_terminal_theme, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - public_pane_id, ), None => TerminalRuntime::spawn( new_id, @@ -363,10 +392,10 @@ impl Tab { scrollback_limit_bytes, host_terminal_theme, shell_config, + launch_env, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - public_pane_id, ), }; let runtime = match runtime { |
