mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-26 00:01:14 +00:00
feat: add plugin manifests and layout api
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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
@@ -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(¶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::<Vec<_>>();
|
||||
plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
|
||||
encode_success(id, ResponseResult::PluginList { plugins })
|
||||
}
|
||||
|
||||
pub(super) fn handle_plugin_unlink(
|
||||
&mut self,
|
||||
id: String,
|
||||
params: PluginUnlinkParams,
|
||||
) -> String {
|
||||
let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else {
|
||||
return invalid_plugin_id(id);
|
||||
};
|
||||
let 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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user