diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 02:43:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 03:47:07 +0300 |
| commit | fbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch) | |
| tree | d21daf93241786f8aa6fde23b41cdb9e15182b79 /src | |
| parent | d35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff) | |
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs.
Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
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 { |
