aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins/mod.rs
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-05 21:56:17 +0200
committerGitHub <noreply@github.com>2026-09-05 21:56:17 +0200
commit6f57f2f62c5d2591647262caf15bc3c962b9c9f8 (patch)
treeaeece6f488a6ec55e15369151fed74f0137a69bb /src/app/api/plugins/mod.rs
parent50ce4ec2ddb8ec621a77d8773aa6df6d9e7441ff (diff)
fix: resolve Windows plugin pane launch paths (#3666)
* fix: resolve Windows plugin pane launch paths refs #3024 * test: compare resolved Windows plugin probe paths refs #3024 * test: compare resolved Windows plugin working directories refs #3024 * test: wait for Windows plugin fixture completion refs #3024
Diffstat (limited to 'src/app/api/plugins/mod.rs')
-rw-r--r--src/app/api/plugins/mod.rs187
1 files changed, 185 insertions, 2 deletions
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 056315c3..f4b6329b 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -440,7 +440,7 @@ impl App {
let Some(entrypoint) = normalize_action_id(&params.entrypoint) else {
return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id");
};
- let Some(pane) = plugin
+ let Some(mut pane) = plugin
.panes
.iter()
.find(|pane| pane.id == entrypoint)
@@ -458,6 +458,12 @@ impl App {
) {
return encode_error(id, code, message);
}
+ pane.command[0] = crate::plugin_command::program_for_cwd(
+ &pane.command[0],
+ std::path::Path::new(&plugin.plugin_root),
+ )
+ .display()
+ .to_string();
let placement = params.placement.unwrap_or(pane.placement);
if placement != PluginPanePlacement::Popup
&& (params.width.is_some() || params.height.is_some())
@@ -987,7 +993,16 @@ platforms = ["linux", "macos", "windows"]
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, canonical_path_string(&root));
+ assert_eq!(
+ plugin.manifest_path,
+ canonical_path_string(&root.join("herdr-plugin.toml"))
+ );
+ assert_eq!(
+ plugin.plugin_root,
+ crate::platform::plugin_runtime_path(&root.canonicalize().unwrap())
+ .display()
+ .to_string()
+ );
assert!(plugin.enabled);
assert_eq!(plugin.build.len(), 1);
assert_eq!(plugin.build[0].command, ["bun", "install"]);
@@ -1466,6 +1481,174 @@ platforms = ["linux", "macos"]
let _ = std::fs::remove_dir_all(root);
}
+ #[cfg(windows)]
+ #[tokio::test]
+ async fn windows_plugin_pane_commands_resolve_from_plugin_root_with_cwd_override() {
+ use std::os::windows::ffi::OsStrExt;
+
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-pane-paths")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+
+ let root = unique_temp_path("plugin-pane-paths");
+ let child_cwd = root.join("child-cwd");
+ std::fs::create_dir_all(&child_cwd).unwrap();
+ let cleanup_root = root.canonicalize().unwrap();
+ let tool = root.join("tool.exe");
+ let long_where_relative = format!("bin/{}/where.exe", "x".repeat(220));
+ let long_where = root.join(&long_where_relative);
+ std::fs::create_dir_all(long_where.parent().unwrap()).unwrap();
+ let shell = std::env::var_os("ComSpec").expect("ComSpec");
+ std::fs::copy(&shell, &tool).unwrap();
+ let where_exe = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap())
+ .join("System32/where.exe");
+ std::fs::copy(where_exe, &long_where).unwrap();
+ let where_probe = "herdr-long-path-probe.exe";
+ std::fs::write(child_cwd.join(where_probe), b"").unwrap();
+ assert!(
+ root.as_os_str().encode_wide().count()
+ < windows_sys::Win32::Foundation::MAX_PATH as usize
+ );
+ assert!(
+ long_where.as_os_str().encode_wide().count()
+ >= windows_sys::Win32::Foundation::MAX_PATH as usize
+ );
+ let script = "@echo off\r\n(echo %1&cd)>capture-%1.tmp\r\nmove /y capture-%1.tmp capture-%1.txt >nul\r\n";
+ std::fs::write(root.join("slot.cmd"), script).unwrap();
+ std::fs::write(child_cwd.join("slot.cmd"), script).unwrap();
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.pane-paths"
+name = "Pane Paths"
+version = "0.1.0"
+min_herdr_version = "0.7.0"
+platforms = ["windows"]
+
+[[panes]]
+id = "explicit"
+title = "Explicit"
+command = ["./tool.exe", "/d", "/c", "slot.cmd", "explicit"]
+
+[[panes]]
+id = "bare"
+title = "Bare"
+command = ["tool.exe", "/d", "/c", "slot.cmd", "bare"]
+
+[[panes]]
+id = "path"
+title = "Path"
+command = ["cmd.exe", "/d", "/c", "slot.cmd", "path"]
+
+[[panes]]
+id = "absolute"
+title = "Absolute"
+command = ['{}', "/d", "/c", "slot.cmd", "absolute"]
+
+[[panes]]
+id = "long"
+title = "Long executable"
+command = ['./{}', "{}"]
+
+[[panes]]
+id = "default"
+title = "Default cwd"
+command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"]
+"#,
+ tool.display(),
+ long_where_relative,
+ where_probe
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ for (entrypoint, cwd) in [
+ ("explicit", Some(&child_cwd)),
+ ("bare", Some(&child_cwd)),
+ ("path", Some(&child_cwd)),
+ ("absolute", Some(&child_cwd)),
+ ("long", Some(&child_cwd)),
+ ("default", None),
+ ] {
+ let expected_cwd = cwd.unwrap_or(&root);
+ let open = app.handle_api_request(Request {
+ id: format!("pane-open-{entrypoint}"),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.pane-paths".into(),
+ entrypoint: entrypoint.into(),
+ placement: Some(PluginPanePlacement::Split),
+ width: None,
+ height: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: cwd.map(|path| path.display().to_string()),
+ focus: false,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else {
+ panic!("failed to open {entrypoint} plugin pane: {open}");
+ };
+ if entrypoint == "long" {
+ let Some((ws_idx, pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else {
+ panic!("opened pane id should parse");
+ };
+ let runtime = app
+ .state
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, ws_idx, pane_id)
+ .expect("long executable pane runtime");
+ let expected = child_cwd.join(where_probe).canonicalize().unwrap();
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ loop {
+ let text = runtime.recent_unwrapped_text(20);
+ if text.lines().any(|line| {
+ std::fs::canonicalize(line.trim()).is_ok_and(|path| path == expected)
+ }) {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "long where.exe output missing from pane: {text:?}"
+ );
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ continue;
+ }
+ let capture = read_capture_when_ready(
+ &expected_cwd.join(format!("capture-{entrypoint}.txt")),
+ || {},
+ );
+ let mut lines = capture.lines();
+ assert_eq!(lines.next(), Some(entrypoint));
+ let actual_cwd = std::fs::canonicalize(lines.next().expect("captured cwd")).unwrap();
+ assert_eq!(actual_cwd, expected_cwd.canonicalize().unwrap());
+ }
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ // PTY actor shutdown is queued, so its Windows handles can outlive `shutdown()`.
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ loop {
+ match std::fs::remove_dir_all(&cleanup_root) {
+ Ok(()) => break,
+ Err(error) => {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "failed to remove {} after runtime shutdown: {error}",
+ cleanup_root.display()
+ );
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ }
+ }
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {