feat: add plugin manifests and layout api

This commit is contained in:
Ogulcan Celik
2026-06-13 02:12:10 +03:00
parent cc7de6c7fe
commit cfc951ffa7
17 changed files with 1687 additions and 25 deletions
@@ -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 <path> [--disabled]
herdr plugin list [--plugin ID]
herdr plugin unlink <plugin_id>
```
`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:
@@ -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:
+1
View File
@@ -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(_)
+61
View File
@@ -99,6 +99,67 @@ pub struct PaneLayoutParams {
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 foreground_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")]
+51
View File
@@ -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<String>,
}
#[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<String>,
pub manifest_path: String,
pub plugin_root: String,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<PluginManifestAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<PluginManifestEventHook>,
}
#[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>,
pub command: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginManifestEventHook {
pub on: String,
pub command: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginActionRegisterParams {
pub plugin_id: String,
+20 -3
View File
@@ -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<String>,
manifests: Vec<AgentManifestInfo>,
},
PluginLinked {
plugin: InstalledPluginInfo,
},
PluginList {
plugins: Vec<InstalledPluginInfo>,
},
PluginUnlinked {
plugin_id: String,
removed: bool,
},
PluginActionRegistered {
action: PluginActionInfo,
},
+150
View File
@@ -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 {
+5
View File
@@ -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",
+12
View File
@@ -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);
}
+761
View File
@@ -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(&params) 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(&params.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(&params.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, &params.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()),
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<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"));
}
}
+309 -5
View File
@@ -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<String>, Option<String>);
impl App {
pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String {
let plugin = match load_plugin_manifest(&params.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::<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(&params.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<String>,
#[serde(default)]
actions: Vec<RawPluginManifestAction>,
#[serde(default)]
events: Vec<RawPluginManifestEventHook>,
}
#[derive(serde::Deserialize)]
struct RawPluginManifestAction {
id: String,
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
contexts: Vec<crate::api::schema::PluginActionContext>,
command: Vec<String>,
}
#[derive(serde::Deserialize)]
struct RawPluginManifestEventHook {
on: String,
command: Vec<String>,
}
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 mut actions = raw
.actions
.into_iter()
.map(normalize_manifest_action)
.collect::<Result<Vec<_>, _>>()?;
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)));
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<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 command = normalize_command(action.command)?;
Ok(PluginManifestAction {
id,
title,
description,
contexts: action.contexts,
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 command = normalize_command(event.command)?;
Ok(PluginManifestEventHook { on, command })
}
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)
}
}
fn normalize_plugin_id(value: &str) -> Option<String> {
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();
+1
View File
@@ -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(),
+6
View File
@@ -10,6 +10,9 @@ use crate::selection::Selection;
pub(crate) type PluginActionRegistry =
std::collections::HashMap<String, crate::api::schema::PluginActionInfo>;
pub(crate) type InstalledPluginRegistry =
std::collections::HashMap<String, crate::api::schema::InstalledPluginInfo>;
#[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<String>,
/// 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(),
+68 -4
View File
@@ -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<i32> {
@@ -15,6 +15,9 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> {
};
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<i32> {
}
}
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 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<i32> {
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<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 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();
@@ -556,6 +617,9 @@ fn print_plugin_response(method: Method) -> std::io::Result<i32> {
fn print_plugin_help() {
eprintln!("herdr plugin commands:");
eprintln!(" herdr plugin link <path> [--disabled]");
eprintln!(" herdr plugin list [--plugin ID]");
eprintln!(" herdr plugin unlink <plugin_id>");
eprintln!(" herdr plugin action <register|list|invoke>");
eprintln!(" herdr plugin storage <get|set|delete|list>");
eprintln!(" herdr plugin pane <open|focus|close>");
+54 -10
View File
@@ -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<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),
)
}
#[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(
+26
View File
@@ -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<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 }),
)
}
fn split_focused_with_runtime(
&mut self,
direction: Direction,
+61
View File
@@ -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();