diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 81c67fb8..08c7893e 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -266,7 +266,17 @@ herdr integration status [--outdated-only] ## Plugins -Plugin commands expose the early plugin host API. They are for local workflow tools that register actions, store namespaced JSON records, or open managed terminal UIs. +Plugin commands expose the early plugin host API. They are for local workflow tools that link manifests, register actions, store namespaced JSON records, or open managed terminal UIs. + +Local manifests: + +```bash +herdr plugin link [--disabled] +herdr plugin list [--plugin ID] +herdr plugin unlink +``` + +`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. The first manifest slice records metadata, actions, and event hooks; manifest commands are executed by later runner work. Actions: diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 7d1f95c4..de9fca76 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -86,10 +86,11 @@ Raw socket method names use dot notation: | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` | | Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Layout | `layout.export`, `layout.apply` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` | | Events | `events.subscribe`, `events.wait` | | Integrations | `integration.install`, `integration.uninstall` | -| Plugins | `plugin.action.register`, `plugin.action.list`, `plugin.action.invoke`, `plugin.storage.get`, `plugin.storage.set`, `plugin.storage.delete`, `plugin.storage.list`, `plugin.pane.open`, `plugin.pane.focus`, `plugin.pane.close` | +| Plugins | `plugin.link`, `plugin.list`, `plugin.unlink`, `plugin.enable`, `plugin.disable`, `plugin.action.list`, `plugin.action.invoke`, `plugin.log.list`, `plugin.pane.open`, `plugin.pane.focus`, `plugin.pane.close` | Some CLI commands are conveniences around these methods. For example, `herdr agent wait` resolves an agent target and then subscribes to pane agent state events. @@ -117,6 +118,59 @@ pane. `pane.neighbor` and `pane.edges` include that same layout snapshot so clients can make the next decision without private layout state. +`layout.export` returns a portable tab layout tree. Omit `tab_id` and `pane_id` +to export the active tab, pass `tab_id` to export that tab, or pass `pane_id` to +export the tab containing that pane. + +```json +{"id":"req_export","method":"layout.export","params":{"tab_id":"w1:t1"}} +``` + +The response includes `workspace_id`, `tab_id`, `zoomed`, `focused_pane_id`, and +`root`. `root` is a BSP tree of `pane` and `split` nodes. Pane nodes can include +`pane_id`, `label`, `cwd`, `foreground_cwd`, and argv `command`. Split nodes use +`direction` (`right` or `down`), `ratio`, `first`, and `second`. + +`layout.apply` creates a fresh tab from a declarative tree. If `tab_id` is +provided, Herdr creates the replacement tab first and then closes the old tab. +This restores structure, labels, cwd, env, and optional argv commands; it does +not preserve live PTYs, scrollback, or running processes. + +```json +{ + "id": "req_apply", + "method": "layout.apply", + "params": { + "workspace_id": "wabc", + "tab_label": "dev", + "focus": true, + "root": { + "type": "split", + "direction": "right", + "ratio": 0.65, + "first": { + "type": "pane", + "label": "editor", + "cwd": "/repo" + }, + "second": { + "type": "pane", + "label": "tests", + "cwd": "/repo", + "command": ["sh", "-c", "just test"], + "env": { "HERDR_ROLE": "tests" } + } + } + } +} +``` + +Process-launching methods accept an `env` object. Herdr applies those key/value +pairs to the newly launched process only. Herdr also injects `HERDR_SOCKET_PATH`, +`HERDR_ENV=1`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID` into +managed pane processes. Herdr-managed variables are authoritative when they +conflict with caller-provided env. + `pane.swap` supports directional and explicit forms: ```json @@ -217,7 +271,41 @@ Worktree commands also emit lifecycle events. `worktree.create` emits `workspace ## Plugin APIs -The plugin API is an early host surface for local workflow tools. It does not require a manifest yet. A plugin process can register actions, store namespaced JSON records, and ask Herdr to open a managed terminal pane for its UI. +The plugin API is an early host surface for local workflow tools. A plugin can be a local executable package with a `herdr-plugin.toml` manifest, or a process that registers actions directly. A plugin process can register actions, store namespaced JSON records, and ask Herdr to open a managed terminal pane for its UI. + +Link a local plugin manifest: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/path/to/plugin","enabled":true}} +``` + +The path can be a plugin directory containing `herdr-plugin.toml` or a direct manifest path. The manifest shape for this slice is: + +```toml +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +description = "Prepare new worktrees" + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] +``` + +List or unlink linked plugins: + +```json +{"id":"req_plugin_list","method":"plugin.list","params":{}} +{"id":"req_plugin_unlink","method":"plugin.unlink","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +In this early slice, linking validates and records manifest metadata. Manifest actions and event hooks are not executed until the runner work lands. Register an action: diff --git a/src/api/mod.rs b/src/api/mod.rs index 04bd0ef7..add0b588 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(_) diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 6f009208..46e3006d 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -99,6 +99,67 @@ pub struct PaneLayoutParams { pub pane_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LayoutExportParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutApplyParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option, + #[serde(default)] + 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, + second: Box, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct LayoutPane { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneNeighborParams { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 032bb4e1..0d515f99 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -7,6 +7,57 @@ 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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginUnlinkParams { + 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, + pub manifest_path: String, + pub plugin_root: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, +} + +#[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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec, + pub command: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestEventHook { + pub on: String, + pub command: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginActionRegisterParams { pub plugin_id: String, diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 09c13249..61f763c2 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -7,11 +7,12 @@ use super::integrations::{ IntegrationInstallResult, IntegrationTarget, IntegrationUninstallResult, }; use super::panes::{ - PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot, PaneNeighborResult, - PaneReadResult, PaneResizeResult, PaneSwapResult, PaneZoomResult, + LayoutDescription, PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot, + PaneNeighborResult, PaneReadResult, PaneResizeResult, PaneSwapResult, PaneZoomResult, }; use super::plugins::{ - PluginActionInfo, PluginInvocationContext, PluginPaneInfo, PluginStorageEntry, + InstalledPluginInfo, PluginActionInfo, PluginInvocationContext, PluginPaneInfo, + PluginStorageEntry, }; use super::server::ServerCapabilities; use super::tabs::TabInfo; @@ -116,6 +117,12 @@ pub enum ResponseResult { PaneLayout { layout: PaneLayoutSnapshot, }, + LayoutExport { + layout: LayoutDescription, + }, + LayoutApply { + layout: LayoutDescription, + }, PaneNeighbor { neighbor: PaneNeighborResult, }, @@ -166,6 +173,16 @@ pub enum ResponseResult { last_result: Option, manifests: Vec, }, + PluginLinked { + plugin: InstalledPluginInfo, + }, + PluginList { + plugins: Vec, + }, + PluginUnlinked { + plugin_id: String, + removed: bool, + }, PluginActionRegistered { action: PluginActionInfo, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index eaca580d..59fc8585 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -604,6 +604,156 @@ fn worktree_lifecycle_events_round_trip() { } } +#[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, + }), + }; + 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, + actions: vec![PluginManifestAction { + id: "bootstrap".into(), + title: "Bootstrap worktree".into(), + description: None, + contexts: vec![PluginActionContext::Workspace], + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + events: vec![PluginManifestEventHook { + on: "worktree.created".into(), + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + }; + + 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 { diff --git a/src/api/server.rs b/src/api/server.rs index 0958e8c8..ce183eac 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -308,6 +308,8 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneMove(_) => "pane.move", Method::PaneZoom(_) => "pane.zoom", Method::PaneLayout(_) => "pane.layout", + Method::LayoutExport(_) => "layout.export", + Method::LayoutApply(_) => "layout.apply", Method::PaneNeighbor(_) => "pane.neighbor", Method::PaneEdges(_) => "pane.edges", Method::PaneFocusDirection(_) => "pane.focus_direction", @@ -331,6 +333,9 @@ 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::PluginActionRegister(_) => "plugin.action.register", Method::PluginActionList(_) => "plugin.action.list", Method::PluginActionInvoke(_) => "plugin.action.invoke", diff --git a/src/app/api.rs b/src/app/api.rs index 66db15a8..d3212b74 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; mod agents; mod env; mod integrations; +mod layouts; mod panes; mod plugins; mod responses; @@ -761,6 +762,8 @@ 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::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) => { @@ -799,6 +802,15 @@ 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::PluginActionRegister(params) => { return self.handle_plugin_action_register(request.id, params); } diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs new file mode 100644 index 00000000..3423b2ea --- /dev/null +++ b/src/app/api/layouts.rs @@ -0,0 +1,761 @@ +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 { + 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 { + 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 { + 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()), + foreground_cwd: tab + .foreground_cwd_for_pane(pane_id, &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 { + 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>, 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/plugins.rs b/src/app/api/plugins.rs index 87a56124..8a437678 100644 --- a/src/app/api/plugins.rs +++ b/src/app/api/plugins.rs @@ -2,11 +2,12 @@ use ratatui::layout::Direction; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ - PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, PluginActionRegisterParams, - PluginInvocationContext, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, - PluginPaneOpenParams, PluginPanePlacement, PluginStorageDeleteParams, PluginStorageEntry, - PluginStorageGetParams, PluginStorageListParams, PluginStorageScope, PluginStorageSetParams, - ResponseResult, + InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, + PluginActionRegisterParams, PluginInvocationContext, PluginLinkParams, PluginListParams, + PluginManifestAction, PluginManifestEventHook, PluginPaneCloseParams, PluginPaneFocusParams, + PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginStorageDeleteParams, + PluginStorageEntry, PluginStorageGetParams, PluginStorageListParams, PluginStorageScope, + PluginStorageSetParams, PluginUnlinkParams, ResponseResult, }; use crate::app::App; @@ -18,6 +19,58 @@ const PLUGIN_STORAGE_VALUE_MAX_BYTES: usize = 256 * 1024; type PluginStoragePrefix = (String, PluginStorageScope, Option, Option); impl App { + pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { + let plugin = match load_plugin_manifest(¶ms.path, params.enabled) { + Ok(plugin) => plugin, + Err((code, message)) => return encode_error(id, code, message), + }; + self.state + .installed_plugins + .insert(plugin.plugin_id.clone(), plugin.clone()); + self.state.mark_session_dirty(); + encode_success(id, ResponseResult::PluginLinked { plugin }) + } + + pub(super) fn handle_plugin_list(&mut self, id: String, params: PluginListParams) -> String { + let plugin_id = match params.plugin_id { + Some(plugin_id) => { + let Some(plugin_id) = normalize_plugin_id(&plugin_id) else { + return invalid_plugin_id(id); + }; + Some(plugin_id) + } + None => None, + }; + 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::>(); + 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 removed = self.state.installed_plugins.remove(&plugin_id).is_some(); + if removed { + self.state.mark_session_dirty(); + } + encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed }) + } + pub(super) fn handle_plugin_action_register( &mut self, id: String, @@ -611,6 +664,159 @@ impl App { } } +#[derive(serde::Deserialize)] +struct RawPluginManifest { + id: String, + name: String, + version: String, + #[serde(default)] + description: Option, + #[serde(default)] + actions: Vec, + #[serde(default)] + events: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestAction { + id: String, + title: String, + #[serde(default)] + description: Option, + #[serde(default)] + contexts: Vec, + command: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestEventHook { + on: String, + command: Vec, +} + +fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result { + 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 mut actions = raw + .actions + .into_iter() + .map(normalize_manifest_action) + .collect::, _>>()?; + actions.sort_by(|a, b| a.id.cmp(&b.id)); + let mut events = raw + .events + .into_iter() + .map(normalize_manifest_event) + .collect::, _>>()?; + events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command))); + + Ok(InstalledPluginInfo { + plugin_id, + name, + version, + description, + manifest_path: manifest_path.display().to_string(), + plugin_root: plugin_root.display().to_string(), + enabled, + actions, + events, + }) +} + +fn normalize_manifest_action( + action: RawPluginManifestAction, +) -> Result { + 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 command = normalize_command(action.command)?; + Ok(PluginManifestAction { + id, + title, + description, + contexts: action.contexts, + command, + }) +} + +fn normalize_manifest_event( + event: RawPluginManifestEventHook, +) -> Result { + let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?; + let command = normalize_command(event.command)?; + Ok(PluginManifestEventHook { on, command }) +} + +fn normalize_command(command: Vec) -> Result, (&'static str, String)> { + let command = command + .into_iter() + .map(|arg| arg.trim().to_string()) + .collect::>(); + 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 { + let value = value.trim().to_string(); + if value.is_empty() { + Err((code, message.to_string())) + } else { + Ok(value) + } +} + fn normalize_plugin_id(value: &str) -> Option { normalize_identifier(value, PLUGIN_ID_MAX_CHARS) } @@ -742,6 +948,7 @@ fn storage_entry( mod tests { use super::*; use crate::api::schema::{Method, PluginActionContext, Request, SuccessResponse}; + use std::time::{SystemTime, UNIX_EPOCH}; fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -760,6 +967,103 @@ mod tests { .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" + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] +"#, + ) + .unwrap(); + manifest + } + + #[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, + }), + }); + 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.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"); + + 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_action_registers_and_invokes_with_context() { let mut app = test_app(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 1d9dc62a..0bc600a2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -542,6 +542,7 @@ impl App { agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), integration_install_messages: Vec::new(), + installed_plugins: std::collections::HashMap::new(), plugin_actions: std::collections::HashMap::new(), plugin_storage: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), diff --git a/src/app/state.rs b/src/app/state.rs index 9802d16d..5983557b 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -10,6 +10,9 @@ use crate::selection::Selection; pub(crate) type PluginActionRegistry = std::collections::HashMap; +pub(crate) type InstalledPluginRegistry = + std::collections::HashMap; + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub(crate) struct PluginStorageKey { pub plugin_id: String, @@ -1404,6 +1407,8 @@ 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, + /// Installed or linked plugins known to this running Herdr instance. + pub(crate) installed_plugins: InstalledPluginRegistry, /// Runtime plugin actions registered through the socket API. pub(crate) plugin_actions: PluginActionRegistry, /// Namespaced plugin storage records. @@ -1740,6 +1745,7 @@ impl AppState { agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus::default(), integration_install_messages: Vec::new(), + installed_plugins: std::collections::HashMap::new(), plugin_actions: std::collections::HashMap::new(), plugin_storage: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 7184bb72..278b0dda 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -2,10 +2,10 @@ use std::collections::HashMap; use crate::api::schema::{ Method, PluginActionContext, PluginActionInvokeParams, PluginActionListParams, - PluginActionRegisterParams, PluginInvocationContext, PluginPaneCloseParams, - PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement, PluginStorageDeleteParams, - PluginStorageGetParams, PluginStorageListParams, PluginStorageScope, PluginStorageSetParams, - Request, SplitDirection, + PluginActionRegisterParams, PluginInvocationContext, PluginLinkParams, PluginListParams, + PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement, + PluginStorageDeleteParams, PluginStorageGetParams, PluginStorageListParams, PluginStorageScope, + PluginStorageSetParams, PluginUnlinkParams, Request, SplitDirection, }; pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { @@ -15,6 +15,9 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { }; match subcommand { + "link" => plugin_link(&args[1..]), + "list" => plugin_list(&args[1..]), + "unlink" => plugin_unlink(&args[1..]), "action" => run_plugin_action_command(&args[1..]), "storage" => run_plugin_storage_command(&args[1..]), "pane" => run_plugin_pane_command(&args[1..]), @@ -29,6 +32,64 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { } } +fn plugin_link(args: &[String]) -> std::io::Result { + let Some(path) = args.first() else { + eprintln!("usage: herdr plugin link [--disabled]"); + return Ok(2); + }; + 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: path.clone(), + enabled, + })) +} + +fn plugin_list(args: &[String]) -> std::io::Result { + let mut plugin_id = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--plugin" => plugin_id = Some(required_value(args, &mut index, "--plugin")?), + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + print_plugin_response(Method::PluginList(PluginListParams { plugin_id })) +} + +fn plugin_unlink(args: &[String]) -> std::io::Result { + let Some(plugin_id) = args.first() else { + eprintln!("usage: herdr plugin unlink "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin unlink "); + return Ok(2); + } + print_plugin_response(Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin_id.clone(), + })) +} + fn run_plugin_action_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_plugin_action_help(); @@ -556,6 +617,9 @@ fn print_plugin_response(method: Method) -> std::io::Result { fn print_plugin_help() { eprintln!("herdr plugin commands:"); + eprintln!(" herdr plugin link [--disabled]"); + eprintln!(" herdr plugin list [--plugin ID]"); + eprintln!(" herdr plugin unlink "); eprintln!(" herdr plugin action "); eprintln!(" herdr plugin storage "); eprintln!(" herdr plugin pane "); diff --git a/src/workspace.rs b/src/workspace.rs index 9a5b2dbf..97a02d35 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -742,6 +742,37 @@ impl Workspace { ) } + #[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, + argv: &[String], + extra_env: Vec<(String, String)>, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + focus_new_pane: bool, + ) -> Option> { + 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), + ) + } + #[allow(clippy::too_many_arguments)] fn split_pane_with_runtime( &mut self, @@ -766,16 +797,29 @@ impl Workspace { 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, - &launch_env, - scrollback_limit_bytes, - host_terminal_theme, - ) + 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( diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 137cffbb..480bc329 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -303,6 +303,32 @@ impl Tab { ) } + pub fn split_focused_argv_command_with_ratio( + &mut self, + direction: Direction, + ratio: f32, + rows: u16, + cols: u16, + cwd: Option, + argv: &[String], + launch_env: &PaneLaunchEnv, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result { + 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 }), + ) + } + fn split_focused_with_runtime( &mut self, direction: Direction, diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index da730b80..d0e4585d 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -2740,6 +2740,67 @@ fn wait_agent_status_exits_when_idle_status_matches() { cleanup_spawned_herdr(herdr, base); } +#[test] +fn plugin_link_list_unlink_cli_smoke_test() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let plugin_dir = base.join("plugins").join("layout"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.layout" +name = "Layout" +version = "0.1.0" +description = "Apply a preferred Herdr layout" + +[[actions]] +id = "apply" +title = "Apply layout" +contexts = ["workspace"] +command = ["sh", "-c", "echo layout"] + +[[events]] +on = "worktree.created" +command = ["sh", "-c", "echo worktree"] +"#, + ) + .unwrap(); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let linked = run_cli_json( + &socket_path, + &["plugin", "link", plugin_dir.to_str().unwrap()], + ); + assert_eq!(linked["result"]["type"], "plugin_linked"); + assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout"); + assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply"); + assert_eq!( + linked["result"]["plugin"]["events"][0]["on"], + "worktree.created" + ); + + let listed = run_cli_json(&socket_path, &["plugin", "list"]); + assert_eq!(listed["result"]["type"], "plugin_list"); + assert_eq!( + listed["result"]["plugins"][0]["plugin_id"], + "example.layout" + ); + + let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]); + assert_eq!(unlinked["result"]["type"], "plugin_unlinked"); + assert_eq!(unlinked["result"]["removed"], true); + + let listed = run_cli_json(&socket_path, &["plugin", "list"]); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + cleanup_spawned_herdr(herdr, base); +} + #[test] fn wait_agent_status_exits_immediately_when_status_already_matches() { let base = unique_test_dir();