From 8f1e252d208ee73682b98e3b1e47b73f4daa4de5 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:38:31 +0800 Subject: [PATCH 01/14] feat: add Herdr plugin for ghzinga links --- README.md | 14 ++ plugins/herdr/README.md | 41 +++++ plugins/herdr/herdr-plugin.toml | 24 +++ plugins/herdr/open.sh | 105 ++++++++++++ plugins/herdr/test/fake-gzg.sh | 6 + plugins/herdr/test/fake-herdr.sh | 27 +++ plugins/herdr/test/test-open.sh | 119 +++++++++++++ plugins/herdr/test/test-viewer.sh | 43 +++++ plugins/herdr/viewer.sh | 13 ++ scripts/ci-local.sh | 7 + scripts/herdr-plugin-live-smoke.sh | 257 +++++++++++++++++++++++++++++ 11 files changed, 656 insertions(+) create mode 100644 plugins/herdr/README.md create mode 100644 plugins/herdr/herdr-plugin.toml create mode 100755 plugins/herdr/open.sh create mode 100755 plugins/herdr/test/fake-gzg.sh create mode 100755 plugins/herdr/test/fake-herdr.sh create mode 100755 plugins/herdr/test/test-open.sh create mode 100755 plugins/herdr/test/test-viewer.sh create mode 100755 plugins/herdr/viewer.sh create mode 100755 scripts/herdr-plugin-live-smoke.sh diff --git a/README.md b/README.md index 829226a8..8d8e654c 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,20 @@ that session, including while another resource is still loading. If the target session is not running, `gzg open` updates the saved session so the resources appear on the next restore. +## Herdr Plugin + +`ghzinga` includes a Herdr plugin for opening GitHub issue and pull request links +from Herdr panes. + +Install it with: + +```sh +herdr plugin install dutifuldev/ghzinga/plugins/herdr +``` + +Then Ctrl-click a GitHub issue or pull request URL inside Herdr. The plugin opens +or reuses a right-side ghzinga pane next to the clicked pane. + ## Refresh `ghzinga` refreshes automatically every 300 seconds by default. Use diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md new file mode 100644 index 00000000..e407b3ad --- /dev/null +++ b/plugins/herdr/README.md @@ -0,0 +1,41 @@ +# Ghzinga for Herdr + +This Herdr plugin opens GitHub issue and pull request links in a ghzinga side +pane. + +## Install + +From the `ghzinga` repository: + +```sh +herdr plugin install dutifuldev/ghzinga/plugins/herdr +``` + +For local development: + +```sh +herdr plugin link /path/to/ghzinga/plugins/herdr +``` + +## Usage + +Inside Herdr, Ctrl-click a GitHub issue or pull request URL: + +```text +https://github.com/dutifuldev/ghzinga/pull/29 +https://github.com/dutifuldev/ghzinga/issues/32 +``` + +The plugin opens a right-side ghzinga pane next to the pane that contained the +link. Later Ctrl-clicks from the same source pane reuse that side pane by +running `gzg open --session ...`. + +## Requirements + +- Herdr 0.7.0 or newer. +- `gzg` or `ghzinga` installed on `PATH`. +- GitHub credentials through `gh auth token`, `GH_TOKEN`, or `GITHUB_TOKEN` for + private repositories. + +Set `GHZINGA_BIN` before launching Herdr if you need to use a non-default +ghzinga binary path. diff --git a/plugins/herdr/herdr-plugin.toml b/plugins/herdr/herdr-plugin.toml new file mode 100644 index 00000000..f2f7b373 --- /dev/null +++ b/plugins/herdr/herdr-plugin.toml @@ -0,0 +1,24 @@ +id = "dutifuldev.ghzinga" +name = "Ghzinga for Herdr" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Open GitHub issue and pull request links in a ghzinga side pane from Herdr." +platforms = ["linux", "macos"] + +[[actions]] +id = "open" +title = "Open in ghzinga" +contexts = ["pane"] +command = ["sh", "open.sh"] + +[[panes]] +id = "viewer" +title = "ghzinga" +placement = "split" +command = ["sh", "viewer.sh"] + +[[link_handlers]] +id = "github-issue-pr" +title = "Open GitHub issue or PR in ghzinga" +pattern = "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(issues|pull)/[0-9]+([/?#].*)?$" +action = "open" diff --git a/plugins/herdr/open.sh b/plugins/herdr/open.sh new file mode 100755 index 00000000..0829258b --- /dev/null +++ b/plugins/herdr/open.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh +set -eu +set -f + +die() { + printf 'ghzinga-herdr: %s\n' "$*" >&2 + exit 1 +} + +normalize_github_url() { + url="$1" + case "$url" in + https://github.com/*) path=${url#https://github.com/} ;; + *) return 1 ;; + esac + + path=${path%%\?*} + path=${path%%\#*} + while [ "${path%/}" != "$path" ]; do + path=${path%/} + done + + old_ifs=$IFS + IFS=/ + set -- $path + IFS=$old_ifs + + owner=${1:-} + repo=${2:-} + kind=${3:-} + number=${4:-} + + [ -n "$owner" ] || return 1 + [ -n "$repo" ] || return 1 + case "$kind" in + issues | pull) ;; + *) return 1 ;; + esac + case "$number" in + '' | *[!0-9]*) return 1 ;; + esac + + printf '%s/%s#%s\n' "$owner" "$repo" "$number" +} + +state_key_for_pane() { + printf '%s\n' "$1" | sed 's/[^A-Za-z0-9_-]/_/g' +} + +json_pane_id() { + sed -n 's/.*"pane_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -n 1 +} + +clicked_url=${HERDR_PLUGIN_CLICKED_URL:-} +[ -n "$clicked_url" ] || die 'HERDR_PLUGIN_CLICKED_URL is not set' + +source_pane=${HERDR_PANE_ID:-} +[ -n "$source_pane" ] || die 'HERDR_PANE_ID is not set' + +target=$(normalize_github_url "$clicked_url") || die "unsupported GitHub issue/PR URL: $clicked_url" + +herdr=${HERDR_BIN_PATH:-herdr} +gzg=${GHZINGA_BIN:-gzg} +plugin_id=${HERDR_PLUGIN_ID:-dutifuldev.ghzinga} +state_dir=${HERDR_PLUGIN_STATE_DIR:-${TMPDIR:-/tmp}/ghzinga-herdr-plugin} +mkdir -p "$state_dir" + +source_key=$(state_key_for_pane "$source_pane") +session="herdr-ghzinga-${source_key}" +state_file="${state_dir}/${source_key}.pane" + +stored_pane= +if [ -f "$state_file" ]; then + stored_pane=$(sed -n '1p' "$state_file") +fi + +if [ -n "$stored_pane" ] && "$herdr" pane get "$stored_pane" >/dev/null 2>&1; then + "$gzg" open --session "$session" "$target" + "$herdr" plugin pane focus "$stored_pane" >/dev/null 2>&1 || true + exit 0 +fi + +set -- "$herdr" plugin pane open \ + --plugin "$plugin_id" \ + --entrypoint viewer \ + --placement split \ + --target-pane "$source_pane" \ + --direction right \ + --env "GHZINGA_TARGET=$target" \ + --env "GHZINGA_SESSION=$session" \ + --focus + +if [ -n "${GHZINGA_BIN:-}" ]; then + set -- "$@" --env "GHZINGA_BIN=$GHZINGA_BIN" +fi + +response=$("$@") +printf '%s\n' "$response" + +opened_pane=$(printf '%s\n' "$response" | json_pane_id) +if [ -n "$opened_pane" ]; then + printf '%s\n' "$opened_pane" >"$state_file" +else + printf 'ghzinga-herdr: warning: could not find opened pane id in Herdr response\n' >&2 +fi diff --git a/plugins/herdr/test/fake-gzg.sh b/plugins/herdr/test/fake-gzg.sh new file mode 100755 index 00000000..2a762a10 --- /dev/null +++ b/plugins/herdr/test/fake-gzg.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +log=${GZG_FAKE_LOG:?GZG_FAKE_LOG is required} +printf '%s\n' "$*" >>"$log" +printf 'fake gzg: %s\n' "$*" diff --git a/plugins/herdr/test/fake-herdr.sh b/plugins/herdr/test/fake-herdr.sh new file mode 100755 index 00000000..edebcfbb --- /dev/null +++ b/plugins/herdr/test/fake-herdr.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +set -eu + +log=${HERDR_FAKE_LOG:?HERDR_FAKE_LOG is required} +printf '%s\n' "$*" >>"$log" + +if [ "$#" -ge 3 ] && [ "$1" = "pane" ] && [ "$2" = "get" ]; then + if [ "${HERDR_FAKE_EXISTING_PANE:-}" = "$3" ]; then + printf '{"id":"fake","result":{"type":"pane_info","pane":{"pane_id":"%s"}}}\n' "$3" + exit 0 + fi + printf 'pane not found\n' >&2 + exit 1 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "focus" ]; then + printf '{"id":"fake","result":{"type":"plugin_pane_focused","plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"%s"}}}}\n' "$4" + exit 0 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "open" ]; then + pane=${HERDR_FAKE_OPENED_PANE:-w1:p9} + printf '{"id":"fake","result":{"type":"plugin_pane_opened","plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"%s"}}}}\n' "$pane" + exit 0 +fi + +printf '{"id":"fake","result":{"type":"ok"}}\n' diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh new file mode 100755 index 00000000..16a32ba4 --- /dev/null +++ b/plugins/herdr/test/test-open.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +work_dir=$(mktemp -d) + +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT INT TERM + +assert_contains() { + file=$1 + expected=$2 + if ! grep -Fq -- "$expected" "$file"; then + printf 'expected to find: %s\n' "$expected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +assert_not_contains() { + file=$1 + unexpected=$2 + if grep -Fq -- "$unexpected" "$file"; then + printf 'expected not to find: %s\n' "$unexpected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +run_open() { + url=$1 + state=$2 + herdr_log=$3 + gzg_log=$4 + shift 4 + env \ + HERDR_PLUGIN_CLICKED_URL="$url" \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_ID="dutifuldev.ghzinga" \ + HERDR_PLUGIN_STATE_DIR="$state" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="$herdr_log" \ + GZG_FAKE_LOG="$gzg_log" \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + "$@" \ + sh "${plugin_dir}/open.sh" >/dev/null +} + +first_state="${work_dir}/first-state" +mkdir -p "$first_state" +first_herdr="${work_dir}/first-herdr.log" +first_gzg="${work_dir}/first-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/29" "$first_state" "$first_herdr" "$first_gzg" +assert_contains "$first_herdr" "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split --target-pane w1:p1 --direction right" +assert_contains "$first_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#29" +assert_contains "$first_herdr" "--env GHZINGA_SESSION=herdr-ghzinga-w1_p1" +assert_contains "$first_herdr" "--env GHZINGA_BIN=${script_dir}/fake-gzg.sh" +assert_contains "${first_state}/w1_p1.pane" "w1:p9" + +issue_state="${work_dir}/issue-state" +mkdir -p "$issue_state" +issue_herdr="${work_dir}/issue-herdr.log" +issue_gzg="${work_dir}/issue-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" "$issue_state" "$issue_herdr" "$issue_gzg" +assert_contains "$issue_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#32" + +reuse_state="${work_dir}/reuse-state" +mkdir -p "$reuse_state" +printf 'w1:p9\n' >"${reuse_state}/w1_p1.pane" +reuse_herdr="${work_dir}/reuse-herdr.log" +reuse_gzg="${work_dir}/reuse-gzg.log" +HERDR_FAKE_EXISTING_PANE=w1:p9 run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" +assert_contains "$reuse_herdr" "pane get w1:p9" +assert_contains "$reuse_herdr" "plugin pane focus w1:p9" +assert_not_contains "$reuse_herdr" "plugin pane open" +assert_contains "$reuse_gzg" "open --session herdr-ghzinga-w1_p1 dutifuldev/ghzinga#33" + +stale_state="${work_dir}/stale-state" +mkdir -p "$stale_state" +printf 'w1:p8\n' >"${stale_state}/w1_p1.pane" +stale_herdr="${work_dir}/stale-herdr.log" +stale_gzg="${work_dir}/stale-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" +assert_contains "$stale_herdr" "pane get w1:p8" +assert_contains "$stale_herdr" "plugin pane open --plugin dutifuldev.ghzinga" + +invalid_err="${work_dir}/invalid.err" +if env \ + HERDR_PLUGIN_CLICKED_URL="https://github.com/dutifuldev/ghzinga/tree/main" \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_STATE_DIR="${work_dir}/invalid-state" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="${work_dir}/invalid-herdr.log" \ + GZG_FAKE_LOG="${work_dir}/invalid-gzg.log" \ + sh "${plugin_dir}/open.sh" 2>"$invalid_err"; then + printf 'expected invalid URL to fail\n' >&2 + exit 1 +fi +assert_contains "$invalid_err" "unsupported GitHub issue/PR URL" + +missing_err="${work_dir}/missing.err" +if env \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_STATE_DIR="${work_dir}/missing-state" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="${work_dir}/missing-herdr.log" \ + GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ + sh "${plugin_dir}/open.sh" 2>"$missing_err"; then + printf 'expected missing URL to fail\n' >&2 + exit 1 +fi +assert_contains "$missing_err" "HERDR_PLUGIN_CLICKED_URL is not set" + +printf 'OK: herdr plugin open tests passed.\n' diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh new file mode 100755 index 00000000..a199b9c5 --- /dev/null +++ b/plugins/herdr/test/test-viewer.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +work_dir=$(mktemp -d) + +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT INT TERM + +assert_contains() { + file=$1 + expected=$2 + if ! grep -Fq -- "$expected" "$file"; then + printf 'expected to find: %s\n' "$expected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +gzg_log="${work_dir}/gzg.log" +env \ + GHZINGA_TARGET="dutifuldev/ghzinga#29" \ + GHZINGA_SESSION="herdr-ghzinga-w1_p1" \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + GZG_FAKE_LOG="$gzg_log" \ + sh "${plugin_dir}/viewer.sh" >/dev/null +assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 dutifuldev/ghzinga#29" + +missing_err="${work_dir}/missing.err" +if env \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ + sh "${plugin_dir}/viewer.sh" 2>"$missing_err"; then + printf 'expected missing target to fail\n' >&2 + exit 1 +fi +assert_contains "$missing_err" "GHZINGA_TARGET is not set" + +printf 'OK: herdr plugin viewer tests passed.\n' diff --git a/plugins/herdr/viewer.sh b/plugins/herdr/viewer.sh new file mode 100755 index 00000000..68edc1d6 --- /dev/null +++ b/plugins/herdr/viewer.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +target=${GHZINGA_TARGET:-} +[ -n "$target" ] || { + printf 'ghzinga-herdr: GHZINGA_TARGET is not set\n' >&2 + exit 1 +} + +session=${GHZINGA_SESSION:-herdr-ghzinga} +gzg=${GHZINGA_BIN:-gzg} + +exec "$gzg" --session "$session" "$target" diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index ca0891dc..2b6a9780 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -17,6 +17,13 @@ slophammer-rs check . --format json scripts/verify-install.sh sh -n scripts/live-smoke.sh GZG_LIVE_SELF_TEST=1 scripts/live-smoke.sh +sh -n scripts/herdr-plugin-live-smoke.sh +HERDR_PLUGIN_LIVE_SELF_TEST=1 scripts/herdr-plugin-live-smoke.sh +for script in plugins/herdr/open.sh plugins/herdr/viewer.sh plugins/herdr/test/*.sh; do + sh -n "$script" +done +plugins/herdr/test/test-open.sh +plugins/herdr/test/test-viewer.sh npx -y @simpledoc/simpledoc check scripts/verify-no-png-captures.sh diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh new file mode 100755 index 00000000..e398223b --- /dev/null +++ b/scripts/herdr-plugin-live-smoke.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +session=${HERDR_PLUGIN_LIVE_SESSION:-ghzinga-plugin-test} +target_url=${HERDR_PLUGIN_LIVE_URL:-https://github.com/openclaw/openclaw/pull/81834} + +if [ "${HERDR_PLUGIN_LIVE_SELF_TEST:-0}" = "1" ]; then + printf 'OK: herdr plugin live smoke self-test passed.\n' + exit 0 +fi + +command -v herdr >/dev/null 2>&1 || { + printf 'herdr is required for the live Herdr smoke test.\n' >&2 + exit 1 +} +command -v python3 >/dev/null 2>&1 || { + printf 'python3 is required for the live Herdr smoke test.\n' >&2 + exit 1 +} + +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null + +HERDR_PLUGIN_LIVE_REPO_ROOT=$repo_root \ +HERDR_PLUGIN_LIVE_SESSION_NAME=$session \ +HERDR_PLUGIN_LIVE_TARGET_URL=$target_url \ +python3 - <<'PY' +import json +import os +import pty +import select +import shutil +import signal +import subprocess +import tempfile +import time +from pathlib import Path + +import errno +import fcntl +import struct +import termios + + +REPO = Path(os.environ["HERDR_PLUGIN_LIVE_REPO_ROOT"]) +SESSION = os.environ["HERDR_PLUGIN_LIVE_SESSION_NAME"] +TARGET_URL = os.environ["HERDR_PLUGIN_LIVE_TARGET_URL"] +ROWS = 40 +COLS = 140 + + +def clean_env(extra=None): + env = os.environ.copy() + for key in ( + "HERDR_ENV", + "HERDR_SOCKET_PATH", + "HERDR_PANE_ID", + "HERDR_TAB_ID", + "HERDR_WORKSPACE_ID", + "HERDR_SESSION", + ): + env.pop(key, None) + env.setdefault("TERM", "xterm-256color") + if extra: + env.update(extra) + return env + + +CLEAN_ENV = clean_env() + + +def run_herdr(args, *, check=True, timeout=20): + result = subprocess.run( + ["herdr", "--session", SESSION, *args], + cwd=REPO, + env=CLEAN_ENV, + text=True, + capture_output=True, + timeout=timeout, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"herdr {' '.join(args)} failed: {detail}") + return result + + +def stop_session(): + subprocess.run( + ["herdr", "session", "stop", SESSION, "--json"], + cwd=REPO, + env=CLEAN_ENV, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + subprocess.run( + ["herdr", "session", "delete", SESSION, "--json"], + cwd=REPO, + env=CLEAN_ENV, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def drain_pty(fd): + while True: + ready, _, _ = select.select([fd], [], [], 0) + if not ready: + return + try: + data = os.read(fd, 8192) + except OSError as exc: + if exc.errno in (errno.EAGAIN, errno.EIO): + return + raise + if not data: + return + + +def parse_json(stdout): + return json.loads(stdout)["result"] + + +def wait_for_panes(fd, *, count=1, timeout=15): + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + drain_pty(fd) + result = run_herdr(["pane", "list"], check=False) + last = result.stderr or result.stdout + if result.returncode == 0: + panes = parse_json(result.stdout)["panes"] + if len(panes) >= count: + return panes + time.sleep(0.25) + raise RuntimeError(f"expected at least {count} Herdr panes. Last output:\n{last}") + + +def wait_for_visible(pane_id, needles, *, timeout=45): + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + result = run_herdr( + ["pane", "read", pane_id, "--source", "visible", "--lines", "80"], + check=False, + ) + if result.returncode == 0: + last = result.stdout + if all(needle in last for needle in needles): + return last + time.sleep(0.5) + raise RuntimeError( + f"pane {pane_id} did not render {needles!r}. Last visible output:\n{last}" + ) + + +def write_executable(path, contents): + path.write_text(contents) + path.chmod(0o755) + + +def main(): + tmp = Path(tempfile.mkdtemp(prefix="ghzinga-herdr-plugin-")) + child_pid = None + try: + stop_session() + + herdr_wrapper = tmp / "herdr-session.sh" + gzg_wrapper = tmp / "gzg-fixture.sh" + state_dir = tmp / "state" + state_dir.mkdir() + + write_executable( + herdr_wrapper, + "#!/bin/sh\n" + "exec herdr --session \"$HERDR_PLUGIN_LIVE_SESSION_NAME\" \"$@\"\n", + ) + write_executable( + gzg_wrapper, + "#!/bin/sh\n" + f"exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\" " + f"--offline-fixture {str(REPO / 'fixtures' / 'pr-81834.json')!r} " + "--no-restore --refresh-seconds 0\n", + ) + + child_pid, fd = pty.fork() + if child_pid == 0: + os.chdir(REPO) + env = clean_env({"TERM": "xterm-256color"}) + os.execvpe("herdr", ["herdr", "--session", SESSION], env) + + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) + fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) + + panes = wait_for_panes(fd, count=1, timeout=20) + source_pane = panes[0]["pane_id"] + + run_herdr(["plugin", "unlink", "dutifuldev.ghzinga"], check=False) + link = parse_json(run_herdr(["plugin", "link", str(REPO / "plugins" / "herdr")]).stdout) + handlers = link["plugin"].get("link_handlers", []) + if not any(handler.get("id") == "github-issue-pr" for handler in handlers): + raise RuntimeError(f"linked plugin did not expose github-issue-pr handler: {handlers}") + + action_env = clean_env( + { + "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, + "HERDR_PLUGIN_CLICKED_URL": TARGET_URL, + "HERDR_PANE_ID": source_pane, + "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", + "HERDR_PLUGIN_STATE_DIR": str(state_dir), + "HERDR_BIN_PATH": str(herdr_wrapper), + "GHZINGA_BIN": str(gzg_wrapper), + } + ) + opened = subprocess.run( + ["sh", str(REPO / "plugins" / "herdr" / "open.sh")], + cwd=REPO, + env=action_env, + text=True, + capture_output=True, + timeout=30, + ) + if opened.returncode != 0: + detail = opened.stderr.strip() or opened.stdout.strip() + raise RuntimeError(f"plugin open entrypoint failed: {detail}") + + panes = wait_for_panes(fd, count=2, timeout=20) + neighbor = run_herdr( + ["pane", "neighbor", "--direction", "right", "--pane", source_pane], + check=True, + ) + neighbor_result = parse_json(neighbor.stdout)["neighbor"] + neighbor_pane = neighbor_result["neighbor_pane_id"] + if neighbor_pane == source_pane: + raise RuntimeError("right neighbor resolved to the source pane") + + visible = wait_for_visible(neighbor_pane, ["Overview", "Activity", "Files"]) + if "openclaw" not in visible.lower(): + raise RuntimeError(f"ghzinga pane did not show the expected fixture:\n{visible}") + + print(f"OK: linked Herdr plugin {link['plugin']['plugin_id']}") + print(f"OK: source pane {source_pane} opened right-side ghzinga pane {neighbor_pane}") + print(f"OK: ghzinga rendered fixture content for {TARGET_URL}") + finally: + if child_pid is not None: + try: + os.kill(child_pid, signal.SIGTERM) + except ProcessLookupError: + pass + stop_session() + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main() +PY From 78d04aabea70c595fd513520264ed83bafbc8362 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:51:38 +0800 Subject: [PATCH 02/14] fix: validate Herdr plugin pane reuse --- plugins/herdr/open.sh | 14 +++++++++++--- plugins/herdr/test/fake-herdr.sh | 8 ++++++-- plugins/herdr/test/test-open.sh | 18 ++++++++++++++++-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/plugins/herdr/open.sh b/plugins/herdr/open.sh index 0829258b..c3549e6e 100755 --- a/plugins/herdr/open.sh +++ b/plugins/herdr/open.sh @@ -51,6 +51,12 @@ json_pane_id() { sed -n 's/.*"pane_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -n 1 } +plugin_focuses_viewer() { + response=$1 + printf '%s\n' "$response" | grep -F "\"plugin_id\":\"$plugin_id\"" >/dev/null 2>&1 || return 1 + printf '%s\n' "$response" | grep -F '"entrypoint":"viewer"' >/dev/null 2>&1 +} + clicked_url=${HERDR_PLUGIN_CLICKED_URL:-} [ -n "$clicked_url" ] || die 'HERDR_PLUGIN_CLICKED_URL is not set' @@ -75,9 +81,11 @@ if [ -f "$state_file" ]; then fi if [ -n "$stored_pane" ] && "$herdr" pane get "$stored_pane" >/dev/null 2>&1; then - "$gzg" open --session "$session" "$target" - "$herdr" plugin pane focus "$stored_pane" >/dev/null 2>&1 || true - exit 0 + if focus_response=$("$herdr" plugin pane focus "$stored_pane" 2>/dev/null) && + plugin_focuses_viewer "$focus_response"; then + "$gzg" open --session "$session" "$target" + exit 0 + fi fi set -- "$herdr" plugin pane open \ diff --git a/plugins/herdr/test/fake-herdr.sh b/plugins/herdr/test/fake-herdr.sh index edebcfbb..21c64b59 100755 --- a/plugins/herdr/test/fake-herdr.sh +++ b/plugins/herdr/test/fake-herdr.sh @@ -14,8 +14,12 @@ if [ "$#" -ge 3 ] && [ "$1" = "pane" ] && [ "$2" = "get" ]; then fi if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "focus" ]; then - printf '{"id":"fake","result":{"type":"plugin_pane_focused","plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"%s"}}}}\n' "$4" - exit 0 + if [ "${HERDR_FAKE_PLUGIN_PANE:-}" = "$4" ]; then + printf '{"id":"fake","result":{"type":"plugin_pane_focused","plugin_pane":{"plugin_id":"%s","entrypoint":"%s","pane":{"pane_id":"%s"}}}}\n' "${HERDR_FAKE_PLUGIN_ID:-dutifuldev.ghzinga}" "${HERDR_FAKE_PLUGIN_ENTRYPOINT:-viewer}" "$4" + exit 0 + fi + printf 'plugin pane not found\n' >&2 + exit 1 fi if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "open" ]; then diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index 16a32ba4..9fdc3a1e 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -24,6 +24,7 @@ assert_contains() { assert_not_contains() { file=$1 unexpected=$2 + [ -f "$file" ] || return 0 if grep -Fq -- "$unexpected" "$file"; then printf 'expected not to find: %s\n' "$unexpected" >&2 printf '%s\n' '--- file ---' >&2 @@ -74,7 +75,7 @@ mkdir -p "$reuse_state" printf 'w1:p9\n' >"${reuse_state}/w1_p1.pane" reuse_herdr="${work_dir}/reuse-herdr.log" reuse_gzg="${work_dir}/reuse-gzg.log" -HERDR_FAKE_EXISTING_PANE=w1:p9 run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" +HERDR_FAKE_EXISTING_PANE=w1:p9 HERDR_FAKE_PLUGIN_PANE=w1:p9 run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" assert_contains "$reuse_herdr" "pane get w1:p9" assert_contains "$reuse_herdr" "plugin pane focus w1:p9" assert_not_contains "$reuse_herdr" "plugin pane open" @@ -85,9 +86,22 @@ mkdir -p "$stale_state" printf 'w1:p8\n' >"${stale_state}/w1_p1.pane" stale_herdr="${work_dir}/stale-herdr.log" stale_gzg="${work_dir}/stale-gzg.log" -run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" +HERDR_FAKE_EXISTING_PANE=w1:p8 run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" assert_contains "$stale_herdr" "pane get w1:p8" +assert_contains "$stale_herdr" "plugin pane focus w1:p8" assert_contains "$stale_herdr" "plugin pane open --plugin dutifuldev.ghzinga" +assert_not_contains "$stale_gzg" "open --session" + +other_plugin_state="${work_dir}/other-plugin-state" +mkdir -p "$other_plugin_state" +printf 'w1:p7\n' >"${other_plugin_state}/w1_p1.pane" +other_plugin_herdr="${work_dir}/other-plugin-herdr.log" +other_plugin_gzg="${work_dir}/other-plugin-gzg.log" +HERDR_FAKE_EXISTING_PANE=w1:p7 HERDR_FAKE_PLUGIN_PANE=w1:p7 HERDR_FAKE_PLUGIN_ID=example.other run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_herdr" "$other_plugin_gzg" +assert_contains "$other_plugin_herdr" "pane get w1:p7" +assert_contains "$other_plugin_herdr" "plugin pane focus w1:p7" +assert_contains "$other_plugin_herdr" "plugin pane open --plugin dutifuldev.ghzinga" +assert_not_contains "$other_plugin_gzg" "open --session" invalid_err="${work_dir}/invalid.err" if env \ From a0167e48789a8758cb2b5889fc4640f1af90db11 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:08:04 +0800 Subject: [PATCH 03/14] fix: scope Herdr plugin sessions --- plugins/herdr/README.md | 7 ++-- plugins/herdr/open.sh | 37 ++++++++++++++++- plugins/herdr/test/test-open.sh | 65 +++++++++++++++++++++++++----- plugins/herdr/test/test-viewer.sh | 12 ++++++ plugins/herdr/viewer.sh | 18 ++++++++- scripts/herdr-plugin-live-smoke.sh | 9 +++++ 6 files changed, 133 insertions(+), 15 deletions(-) diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index e407b3ad..d1235c57 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -27,13 +27,14 @@ https://github.com/dutifuldev/ghzinga/issues/32 ``` The plugin opens a right-side ghzinga pane next to the pane that contained the -link. Later Ctrl-clicks from the same source pane reuse that side pane by -running `gzg open --session ...`. +link. Later Ctrl-clicks from the same source pane in the same Herdr session +reuse that side pane by running `gzg open --session ...`. ## Requirements - Herdr 0.7.0 or newer. -- `gzg` or `ghzinga` installed on `PATH`. +- `gzg` or `ghzinga` installed on `PATH`. The plugin prefers `gzg`, then + falls back to `ghzinga`. - GitHub credentials through `gh auth token`, `GH_TOKEN`, or `GITHUB_TOKEN` for private repositories. diff --git a/plugins/herdr/open.sh b/plugins/herdr/open.sh index c3549e6e..0d95ffcf 100755 --- a/plugins/herdr/open.sh +++ b/plugins/herdr/open.sh @@ -47,6 +47,22 @@ state_key_for_pane() { printf '%s\n' "$1" | sed 's/[^A-Za-z0-9_-]/_/g' } +stable_key_for_text() { + printf '%s\n' "$1" | cksum | sed 's/[[:space:]].*//' +} + +herdr_scope_key() { + if [ -n "${HERDR_SOCKET_PATH:-}" ]; then + printf 'socket_%s\n' "$(stable_key_for_text "$HERDR_SOCKET_PATH")" + return + fi + if [ -n "${HERDR_SESSION:-}" ]; then + printf 'session_%s\n' "$(stable_key_for_text "$HERDR_SESSION")" + return + fi + printf 'default\n' +} + json_pane_id() { sed -n 's/.*"pane_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -n 1 } @@ -57,6 +73,22 @@ plugin_focuses_viewer() { printf '%s\n' "$response" | grep -F '"entrypoint":"viewer"' >/dev/null 2>&1 } +ghzinga_bin() { + if [ -n "${GHZINGA_BIN:-}" ]; then + printf '%s\n' "$GHZINGA_BIN" + return + fi + if command -v gzg >/dev/null 2>&1; then + printf '%s\n' 'gzg' + return + fi + if command -v ghzinga >/dev/null 2>&1; then + printf '%s\n' 'ghzinga' + return + fi + printf '%s\n' 'gzg' +} + clicked_url=${HERDR_PLUGIN_CLICKED_URL:-} [ -n "$clicked_url" ] || die 'HERDR_PLUGIN_CLICKED_URL is not set' @@ -66,12 +98,13 @@ source_pane=${HERDR_PANE_ID:-} target=$(normalize_github_url "$clicked_url") || die "unsupported GitHub issue/PR URL: $clicked_url" herdr=${HERDR_BIN_PATH:-herdr} -gzg=${GHZINGA_BIN:-gzg} +gzg=$(ghzinga_bin) plugin_id=${HERDR_PLUGIN_ID:-dutifuldev.ghzinga} state_dir=${HERDR_PLUGIN_STATE_DIR:-${TMPDIR:-/tmp}/ghzinga-herdr-plugin} mkdir -p "$state_dir" -source_key=$(state_key_for_pane "$source_pane") +scope_key=$(herdr_scope_key) +source_key="${scope_key}_$(state_key_for_pane "$source_pane")" session="herdr-ghzinga-${source_key}" state_file="${state_dir}/${source_key}.pane" diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index 9fdc3a1e..ca797556 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -10,6 +10,14 @@ cleanup() { } trap cleanup EXIT INT TERM +herdr_socket="${work_dir}/herdr-a.sock" +herdr_scope_key="socket_$(printf '%s\n' "$herdr_socket" | cksum | sed 's/[[:space:]].*//')" +herdr_session="herdr-ghzinga-${herdr_scope_key}_w1_p1" + +state_file_for() { + printf '%s/%s_w1_p1.pane\n' "$1" "$herdr_scope_key" +} + assert_contains() { file=$1 expected=$2 @@ -44,6 +52,7 @@ run_open() { HERDR_PANE_ID="w1:p1" \ HERDR_PLUGIN_ID="dutifuldev.ghzinga" \ HERDR_PLUGIN_STATE_DIR="$state" \ + HERDR_SOCKET_PATH="$herdr_socket" \ HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ HERDR_FAKE_LOG="$herdr_log" \ GZG_FAKE_LOG="$gzg_log" \ @@ -59,9 +68,26 @@ first_gzg="${work_dir}/first-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/29" "$first_state" "$first_herdr" "$first_gzg" assert_contains "$first_herdr" "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split --target-pane w1:p1 --direction right" assert_contains "$first_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#29" -assert_contains "$first_herdr" "--env GHZINGA_SESSION=herdr-ghzinga-w1_p1" +assert_contains "$first_herdr" "--env GHZINGA_SESSION=$herdr_session" assert_contains "$first_herdr" "--env GHZINGA_BIN=${script_dir}/fake-gzg.sh" -assert_contains "${first_state}/w1_p1.pane" "w1:p9" +assert_contains "$(state_file_for "$first_state")" "w1:p9" + +second_socket="${work_dir}/herdr-b.sock" +second_scope_key="socket_$(printf '%s\n' "$second_socket" | cksum | sed 's/[[:space:]].*//')" +shared_state="${work_dir}/shared-state" +mkdir -p "$shared_state" +shared_first_herdr="${work_dir}/shared-first-herdr.log" +shared_first_gzg="${work_dir}/shared-first-gzg.log" +shared_second_herdr="${work_dir}/shared-second-herdr.log" +shared_second_gzg="${work_dir}/shared-second-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/30" "$shared_state" "$shared_first_herdr" "$shared_first_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p9 +run_open "https://github.com/dutifuldev/ghzinga/pull/31" "$shared_state" "$shared_second_herdr" "$shared_second_gzg" \ + HERDR_SOCKET_PATH="$second_socket" \ + HERDR_FAKE_OPENED_PANE=w1:p8 +assert_contains "${shared_state}/${herdr_scope_key}_w1_p1.pane" "w1:p9" +assert_contains "${shared_state}/${second_scope_key}_w1_p1.pane" "w1:p8" +assert_contains "$shared_second_herdr" "--env GHZINGA_SESSION=herdr-ghzinga-${second_scope_key}_w1_p1" issue_state="${work_dir}/issue-state" mkdir -p "$issue_state" @@ -72,21 +98,39 @@ assert_contains "$issue_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#32" reuse_state="${work_dir}/reuse-state" mkdir -p "$reuse_state" -printf 'w1:p9\n' >"${reuse_state}/w1_p1.pane" +printf 'w1:p9\n' >"$(state_file_for "$reuse_state")" reuse_herdr="${work_dir}/reuse-herdr.log" reuse_gzg="${work_dir}/reuse-gzg.log" -HERDR_FAKE_EXISTING_PANE=w1:p9 HERDR_FAKE_PLUGIN_PANE=w1:p9 run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" +run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 assert_contains "$reuse_herdr" "pane get w1:p9" assert_contains "$reuse_herdr" "plugin pane focus w1:p9" assert_not_contains "$reuse_herdr" "plugin pane open" -assert_contains "$reuse_gzg" "open --session herdr-ghzinga-w1_p1 dutifuldev/ghzinga#33" +assert_contains "$reuse_gzg" "open --session $herdr_session dutifuldev/ghzinga#33" + +fallback_bin="${work_dir}/fallback-bin" +mkdir -p "$fallback_bin" +ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" +fallback_state="${work_dir}/fallback-state" +mkdir -p "$fallback_state" +printf 'w1:p6\n' >"$(state_file_for "$fallback_state")" +fallback_herdr="${work_dir}/fallback-herdr.log" +fallback_gzg="${work_dir}/fallback-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fallback_herdr" "$fallback_gzg" \ + PATH="$fallback_bin:/usr/bin:/bin" \ + GHZINGA_BIN= \ + HERDR_FAKE_EXISTING_PANE=w1:p6 \ + HERDR_FAKE_PLUGIN_PANE=w1:p6 +assert_contains "$fallback_gzg" "open --session $herdr_session dutifuldev/ghzinga#36" stale_state="${work_dir}/stale-state" mkdir -p "$stale_state" -printf 'w1:p8\n' >"${stale_state}/w1_p1.pane" +printf 'w1:p8\n' >"$(state_file_for "$stale_state")" stale_herdr="${work_dir}/stale-herdr.log" stale_gzg="${work_dir}/stale-gzg.log" -HERDR_FAKE_EXISTING_PANE=w1:p8 run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" +run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p8 assert_contains "$stale_herdr" "pane get w1:p8" assert_contains "$stale_herdr" "plugin pane focus w1:p8" assert_contains "$stale_herdr" "plugin pane open --plugin dutifuldev.ghzinga" @@ -94,10 +138,13 @@ assert_not_contains "$stale_gzg" "open --session" other_plugin_state="${work_dir}/other-plugin-state" mkdir -p "$other_plugin_state" -printf 'w1:p7\n' >"${other_plugin_state}/w1_p1.pane" +printf 'w1:p7\n' >"$(state_file_for "$other_plugin_state")" other_plugin_herdr="${work_dir}/other-plugin-herdr.log" other_plugin_gzg="${work_dir}/other-plugin-gzg.log" -HERDR_FAKE_EXISTING_PANE=w1:p7 HERDR_FAKE_PLUGIN_PANE=w1:p7 HERDR_FAKE_PLUGIN_ID=example.other run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_herdr" "$other_plugin_gzg" +run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_herdr" "$other_plugin_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p7 \ + HERDR_FAKE_PLUGIN_PANE=w1:p7 \ + HERDR_FAKE_PLUGIN_ID=example.other assert_contains "$other_plugin_herdr" "pane get w1:p7" assert_contains "$other_plugin_herdr" "plugin pane focus w1:p7" assert_contains "$other_plugin_herdr" "plugin pane open --plugin dutifuldev.ghzinga" diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh index a199b9c5..fd99b65f 100755 --- a/plugins/herdr/test/test-viewer.sh +++ b/plugins/herdr/test/test-viewer.sh @@ -30,6 +30,18 @@ env \ sh "${plugin_dir}/viewer.sh" >/dev/null assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 dutifuldev/ghzinga#29" +fallback_bin="${work_dir}/fallback-bin" +mkdir -p "$fallback_bin" +ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" +fallback_log="${work_dir}/fallback-gzg.log" +env \ + PATH="$fallback_bin:/usr/bin:/bin" \ + GHZINGA_TARGET="dutifuldev/ghzinga#30" \ + GHZINGA_SESSION="herdr-ghzinga-fallback" \ + GZG_FAKE_LOG="$fallback_log" \ + sh "${plugin_dir}/viewer.sh" >/dev/null +assert_contains "$fallback_log" "--session herdr-ghzinga-fallback dutifuldev/ghzinga#30" + missing_err="${work_dir}/missing.err" if env \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ diff --git a/plugins/herdr/viewer.sh b/plugins/herdr/viewer.sh index 68edc1d6..3cc0adc9 100755 --- a/plugins/herdr/viewer.sh +++ b/plugins/herdr/viewer.sh @@ -1,6 +1,22 @@ #!/usr/bin/env sh set -eu +ghzinga_bin() { + if [ -n "${GHZINGA_BIN:-}" ]; then + printf '%s\n' "$GHZINGA_BIN" + return + fi + if command -v gzg >/dev/null 2>&1; then + printf '%s\n' 'gzg' + return + fi + if command -v ghzinga >/dev/null 2>&1; then + printf '%s\n' 'ghzinga' + return + fi + printf '%s\n' 'gzg' +} + target=${GHZINGA_TARGET:-} [ -n "$target" ] || { printf 'ghzinga-herdr: GHZINGA_TARGET is not set\n' >&2 @@ -8,6 +24,6 @@ target=${GHZINGA_TARGET:-} } session=${GHZINGA_SESSION:-herdr-ghzinga} -gzg=${GHZINGA_BIN:-gzg} +gzg=$(ghzinga_bin) exec "$gzg" --session "$session" "$target" diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh index e398223b..7fdbaf6d 100755 --- a/scripts/herdr-plugin-live-smoke.sh +++ b/scripts/herdr-plugin-live-smoke.sh @@ -84,6 +84,14 @@ def run_herdr(args, *, check=True, timeout=20): return result +def herdr_socket_path(): + status = run_herdr(["status", "server"]).stdout + for line in status.splitlines(): + if line.startswith("socket: "): + return line.removeprefix("socket: ").strip() + raise RuntimeError(f"could not find Herdr socket path in status output: {status}") + + def stop_session(): subprocess.run( ["herdr", "session", "stop", SESSION, "--json"], @@ -207,6 +215,7 @@ def main(): "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, "HERDR_PLUGIN_CLICKED_URL": TARGET_URL, "HERDR_PANE_ID": source_pane, + "HERDR_SOCKET_PATH": herdr_socket_path(), "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", "HERDR_PLUGIN_STATE_DIR": str(state_dir), "HERDR_BIN_PATH": str(herdr_wrapper), From 7d01d1eb72666db7426e41eba7934e37e71e8e6c Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:18:21 +0800 Subject: [PATCH 04/14] fix: preserve Herdr link URL kind --- plugins/herdr/open.sh | 2 +- plugins/herdr/test/test-open.sh | 8 ++++---- plugins/herdr/test/test-viewer.sh | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/herdr/open.sh b/plugins/herdr/open.sh index 0d95ffcf..2b71d276 100755 --- a/plugins/herdr/open.sh +++ b/plugins/herdr/open.sh @@ -40,7 +40,7 @@ normalize_github_url() { '' | *[!0-9]*) return 1 ;; esac - printf '%s/%s#%s\n' "$owner" "$repo" "$number" + printf 'https://github.com/%s/%s/%s/%s\n' "$owner" "$repo" "$kind" "$number" } state_key_for_pane() { diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index ca797556..56afd48b 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -67,7 +67,7 @@ first_herdr="${work_dir}/first-herdr.log" first_gzg="${work_dir}/first-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/29" "$first_state" "$first_herdr" "$first_gzg" assert_contains "$first_herdr" "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split --target-pane w1:p1 --direction right" -assert_contains "$first_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#29" +assert_contains "$first_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/pull/29" assert_contains "$first_herdr" "--env GHZINGA_SESSION=$herdr_session" assert_contains "$first_herdr" "--env GHZINGA_BIN=${script_dir}/fake-gzg.sh" assert_contains "$(state_file_for "$first_state")" "w1:p9" @@ -94,7 +94,7 @@ mkdir -p "$issue_state" issue_herdr="${work_dir}/issue-herdr.log" issue_gzg="${work_dir}/issue-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" "$issue_state" "$issue_herdr" "$issue_gzg" -assert_contains "$issue_herdr" "--env GHZINGA_TARGET=dutifuldev/ghzinga#32" +assert_contains "$issue_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/issues/32" reuse_state="${work_dir}/reuse-state" mkdir -p "$reuse_state" @@ -107,7 +107,7 @@ run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_ assert_contains "$reuse_herdr" "pane get w1:p9" assert_contains "$reuse_herdr" "plugin pane focus w1:p9" assert_not_contains "$reuse_herdr" "plugin pane open" -assert_contains "$reuse_gzg" "open --session $herdr_session dutifuldev/ghzinga#33" +assert_contains "$reuse_gzg" "open --session $herdr_session https://github.com/dutifuldev/ghzinga/pull/33" fallback_bin="${work_dir}/fallback-bin" mkdir -p "$fallback_bin" @@ -122,7 +122,7 @@ run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fal GHZINGA_BIN= \ HERDR_FAKE_EXISTING_PANE=w1:p6 \ HERDR_FAKE_PLUGIN_PANE=w1:p6 -assert_contains "$fallback_gzg" "open --session $herdr_session dutifuldev/ghzinga#36" +assert_contains "$fallback_gzg" "open --session $herdr_session https://github.com/dutifuldev/ghzinga/pull/36" stale_state="${work_dir}/stale-state" mkdir -p "$stale_state" diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh index fd99b65f..f92a3f70 100755 --- a/plugins/herdr/test/test-viewer.sh +++ b/plugins/herdr/test/test-viewer.sh @@ -23,12 +23,12 @@ assert_contains() { gzg_log="${work_dir}/gzg.log" env \ - GHZINGA_TARGET="dutifuldev/ghzinga#29" \ + GHZINGA_TARGET="https://github.com/dutifuldev/ghzinga/pull/29" \ GHZINGA_SESSION="herdr-ghzinga-w1_p1" \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ GZG_FAKE_LOG="$gzg_log" \ sh "${plugin_dir}/viewer.sh" >/dev/null -assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 dutifuldev/ghzinga#29" +assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 https://github.com/dutifuldev/ghzinga/pull/29" fallback_bin="${work_dir}/fallback-bin" mkdir -p "$fallback_bin" @@ -36,11 +36,11 @@ ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" fallback_log="${work_dir}/fallback-gzg.log" env \ PATH="$fallback_bin:/usr/bin:/bin" \ - GHZINGA_TARGET="dutifuldev/ghzinga#30" \ + GHZINGA_TARGET="https://github.com/dutifuldev/ghzinga/pull/30" \ GHZINGA_SESSION="herdr-ghzinga-fallback" \ GZG_FAKE_LOG="$fallback_log" \ sh "${plugin_dir}/viewer.sh" >/dev/null -assert_contains "$fallback_log" "--session herdr-ghzinga-fallback dutifuldev/ghzinga#30" +assert_contains "$fallback_log" "--session herdr-ghzinga-fallback https://github.com/dutifuldev/ghzinga/pull/30" missing_err="${work_dir}/missing.err" if env \ From 0b6fe9e9c10815dc5cf87e1db661a1a5bdff0f84 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:33:15 +0800 Subject: [PATCH 05/14] ci: trigger checks for Herdr plugin changes --- .github/workflows/ci.yml | 2 ++ tests/architecture.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1869798..200d0cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: - slophammer.yml - fixtures/** - captures/** + - plugins/** - scripts/** - src/** - tests/** @@ -23,6 +24,7 @@ on: - slophammer.yml - fixtures/** - captures/** + - plugins/** - scripts/** - src/** - tests/** diff --git a/tests/architecture.rs b/tests/architecture.rs index c4758888..1d7873b7 100644 --- a/tests/architecture.rs +++ b/tests/architecture.rs @@ -232,6 +232,24 @@ fn ci_workflow_delegates_to_full_local_gate() { assert!(workflow.contains("workflow_dispatch:")); assert!(workflow.contains("scripts/ci-local.sh")); + for expected_path in [ + ".github/workflows/ci.yml", + "Cargo.lock", + "Cargo.toml", + "slophammer.yml", + "fixtures/**", + "captures/**", + "plugins/**", + "scripts/**", + "src/**", + "tests/**", + ] { + assert!( + workflow.contains(expected_path), + "CI workflow path filters are missing `{expected_path}`" + ); + } + for expected_check in [ "cargo fmt --check", "cargo check", From af4c77dae7277b7be9891c7115f4c9f15211f785 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:46:48 +0800 Subject: [PATCH 06/14] test: isolate Herdr plugin live smoke --- scripts/herdr-plugin-live-smoke.sh | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh index 7fdbaf6d..b85fdf08 100755 --- a/scripts/herdr-plugin-live-smoke.sh +++ b/scripts/herdr-plugin-live-smoke.sh @@ -2,7 +2,7 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -session=${HERDR_PLUGIN_LIVE_SESSION:-ghzinga-plugin-test} +session=${HERDR_PLUGIN_LIVE_SESSION:-gzgplug} target_url=${HERDR_PLUGIN_LIVE_URL:-https://github.com/openclaw/openclaw/pull/81834} if [ "${HERDR_PLUGIN_LIVE_SELF_TEST:-0}" = "1" ]; then @@ -47,6 +47,7 @@ SESSION = os.environ["HERDR_PLUGIN_LIVE_SESSION_NAME"] TARGET_URL = os.environ["HERDR_PLUGIN_LIVE_TARGET_URL"] ROWS = 40 COLS = 140 +ISOLATED_ENV = {} def clean_env(extra=None): @@ -58,22 +59,21 @@ def clean_env(extra=None): "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_SESSION", + "HERDR_CONFIG_PATH", ): env.pop(key, None) env.setdefault("TERM", "xterm-256color") + env.update(ISOLATED_ENV) if extra: env.update(extra) return env -CLEAN_ENV = clean_env() - - def run_herdr(args, *, check=True, timeout=20): result = subprocess.run( ["herdr", "--session", SESSION, *args], cwd=REPO, - env=CLEAN_ENV, + env=clean_env(), text=True, capture_output=True, timeout=timeout, @@ -96,7 +96,7 @@ def stop_session(): subprocess.run( ["herdr", "session", "stop", SESSION, "--json"], cwd=REPO, - env=CLEAN_ENV, + env=clean_env(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, @@ -104,7 +104,7 @@ def stop_session(): subprocess.run( ["herdr", "session", "delete", SESSION, "--json"], cwd=REPO, - env=CLEAN_ENV, + env=clean_env(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, @@ -169,9 +169,23 @@ def write_executable(path, contents): def main(): - tmp = Path(tempfile.mkdtemp(prefix="ghzinga-herdr-plugin-")) + global ISOLATED_ENV + tmp_parent = Path(os.environ.get("HERDR_PLUGIN_LIVE_TMPDIR", "/tmp")) + tmp = Path(tempfile.mkdtemp(prefix="gzg-hp-", dir=tmp_parent)) child_pid = None try: + xdg_config = tmp / "xdg-config" + xdg_state = tmp / "xdg-state" + xdg_cache = tmp / "xdg-cache" + xdg_config.mkdir() + xdg_state.mkdir() + xdg_cache.mkdir() + ISOLATED_ENV = { + "XDG_CONFIG_HOME": str(xdg_config), + "XDG_STATE_HOME": str(xdg_state), + "XDG_CACHE_HOME": str(xdg_cache), + } + stop_session() herdr_wrapper = tmp / "herdr-session.sh" From 39484e177d9fbad7988468d0077a46daccf7cf80 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:26:55 +0800 Subject: [PATCH 07/14] feat: move Herdr plugin logic into ghzinga --- plugins/herdr/README.md | 6 +- plugins/herdr/herdr-plugin.toml | 4 +- plugins/herdr/open.sh | 146 -------- plugins/herdr/test/test-open.sh | 75 +++- plugins/herdr/test/test-viewer.sh | 20 +- plugins/herdr/viewer.sh | 29 -- scripts/ci-local.sh | 2 +- scripts/herdr-plugin-live-smoke.sh | 6 +- src/herdr_plugin.rs | 580 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/runner.rs | 1 + 11 files changed, 655 insertions(+), 215 deletions(-) delete mode 100755 plugins/herdr/open.sh delete mode 100755 plugins/herdr/viewer.sh create mode 100644 src/herdr_plugin.rs diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index d1235c57..65bbe858 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -33,10 +33,10 @@ reuse that side pane by running `gzg open --session ...`. ## Requirements - Herdr 0.7.0 or newer. -- `gzg` or `ghzinga` installed on `PATH`. The plugin prefers `gzg`, then - falls back to `ghzinga`. +- `gzg` installed on `PATH`. The Herdr entrypoints call + `gzg herdr-plugin open` and `gzg herdr-plugin viewer`. - GitHub credentials through `gh auth token`, `GH_TOKEN`, or `GITHUB_TOKEN` for private repositories. Set `GHZINGA_BIN` before launching Herdr if you need to use a non-default -ghzinga binary path. +ghzinga binary path for the viewer process. Normal installs do not need this. diff --git a/plugins/herdr/herdr-plugin.toml b/plugins/herdr/herdr-plugin.toml index f2f7b373..c1004444 100644 --- a/plugins/herdr/herdr-plugin.toml +++ b/plugins/herdr/herdr-plugin.toml @@ -9,13 +9,13 @@ platforms = ["linux", "macos"] id = "open" title = "Open in ghzinga" contexts = ["pane"] -command = ["sh", "open.sh"] +command = ["gzg", "herdr-plugin", "open"] [[panes]] id = "viewer" title = "ghzinga" placement = "split" -command = ["sh", "viewer.sh"] +command = ["gzg", "herdr-plugin", "viewer"] [[link_handlers]] id = "github-issue-pr" diff --git a/plugins/herdr/open.sh b/plugins/herdr/open.sh deleted file mode 100755 index 2b71d276..00000000 --- a/plugins/herdr/open.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env sh -set -eu -set -f - -die() { - printf 'ghzinga-herdr: %s\n' "$*" >&2 - exit 1 -} - -normalize_github_url() { - url="$1" - case "$url" in - https://github.com/*) path=${url#https://github.com/} ;; - *) return 1 ;; - esac - - path=${path%%\?*} - path=${path%%\#*} - while [ "${path%/}" != "$path" ]; do - path=${path%/} - done - - old_ifs=$IFS - IFS=/ - set -- $path - IFS=$old_ifs - - owner=${1:-} - repo=${2:-} - kind=${3:-} - number=${4:-} - - [ -n "$owner" ] || return 1 - [ -n "$repo" ] || return 1 - case "$kind" in - issues | pull) ;; - *) return 1 ;; - esac - case "$number" in - '' | *[!0-9]*) return 1 ;; - esac - - printf 'https://github.com/%s/%s/%s/%s\n' "$owner" "$repo" "$kind" "$number" -} - -state_key_for_pane() { - printf '%s\n' "$1" | sed 's/[^A-Za-z0-9_-]/_/g' -} - -stable_key_for_text() { - printf '%s\n' "$1" | cksum | sed 's/[[:space:]].*//' -} - -herdr_scope_key() { - if [ -n "${HERDR_SOCKET_PATH:-}" ]; then - printf 'socket_%s\n' "$(stable_key_for_text "$HERDR_SOCKET_PATH")" - return - fi - if [ -n "${HERDR_SESSION:-}" ]; then - printf 'session_%s\n' "$(stable_key_for_text "$HERDR_SESSION")" - return - fi - printf 'default\n' -} - -json_pane_id() { - sed -n 's/.*"pane_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -n 1 -} - -plugin_focuses_viewer() { - response=$1 - printf '%s\n' "$response" | grep -F "\"plugin_id\":\"$plugin_id\"" >/dev/null 2>&1 || return 1 - printf '%s\n' "$response" | grep -F '"entrypoint":"viewer"' >/dev/null 2>&1 -} - -ghzinga_bin() { - if [ -n "${GHZINGA_BIN:-}" ]; then - printf '%s\n' "$GHZINGA_BIN" - return - fi - if command -v gzg >/dev/null 2>&1; then - printf '%s\n' 'gzg' - return - fi - if command -v ghzinga >/dev/null 2>&1; then - printf '%s\n' 'ghzinga' - return - fi - printf '%s\n' 'gzg' -} - -clicked_url=${HERDR_PLUGIN_CLICKED_URL:-} -[ -n "$clicked_url" ] || die 'HERDR_PLUGIN_CLICKED_URL is not set' - -source_pane=${HERDR_PANE_ID:-} -[ -n "$source_pane" ] || die 'HERDR_PANE_ID is not set' - -target=$(normalize_github_url "$clicked_url") || die "unsupported GitHub issue/PR URL: $clicked_url" - -herdr=${HERDR_BIN_PATH:-herdr} -gzg=$(ghzinga_bin) -plugin_id=${HERDR_PLUGIN_ID:-dutifuldev.ghzinga} -state_dir=${HERDR_PLUGIN_STATE_DIR:-${TMPDIR:-/tmp}/ghzinga-herdr-plugin} -mkdir -p "$state_dir" - -scope_key=$(herdr_scope_key) -source_key="${scope_key}_$(state_key_for_pane "$source_pane")" -session="herdr-ghzinga-${source_key}" -state_file="${state_dir}/${source_key}.pane" - -stored_pane= -if [ -f "$state_file" ]; then - stored_pane=$(sed -n '1p' "$state_file") -fi - -if [ -n "$stored_pane" ] && "$herdr" pane get "$stored_pane" >/dev/null 2>&1; then - if focus_response=$("$herdr" plugin pane focus "$stored_pane" 2>/dev/null) && - plugin_focuses_viewer "$focus_response"; then - "$gzg" open --session "$session" "$target" - exit 0 - fi -fi - -set -- "$herdr" plugin pane open \ - --plugin "$plugin_id" \ - --entrypoint viewer \ - --placement split \ - --target-pane "$source_pane" \ - --direction right \ - --env "GHZINGA_TARGET=$target" \ - --env "GHZINGA_SESSION=$session" \ - --focus - -if [ -n "${GHZINGA_BIN:-}" ]; then - set -- "$@" --env "GHZINGA_BIN=$GHZINGA_BIN" -fi - -response=$("$@") -printf '%s\n' "$response" - -opened_pane=$(printf '%s\n' "$response" | json_pane_id) -if [ -n "$opened_pane" ]; then - printf '%s\n' "$opened_pane" >"$state_file" -else - printf 'ghzinga-herdr: warning: could not find opened pane id in Herdr response\n' >&2 -fi diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index 56afd48b..455e8d7d 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -3,6 +3,8 @@ set -eu script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +repo_root=$(CDPATH= cd -- "${plugin_dir}/../.." && pwd) +gzg_bin=${GHZINGA_TEST_BIN:-${repo_root}/target/debug/gzg} work_dir=$(mktemp -d) cleanup() { @@ -11,12 +13,8 @@ cleanup() { trap cleanup EXIT INT TERM herdr_socket="${work_dir}/herdr-a.sock" -herdr_scope_key="socket_$(printf '%s\n' "$herdr_socket" | cksum | sed 's/[[:space:]].*//')" -herdr_session="herdr-ghzinga-${herdr_scope_key}_w1_p1" -state_file_for() { - printf '%s/%s_w1_p1.pane\n' "$1" "$herdr_scope_key" -} +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null assert_contains() { file=$1 @@ -41,6 +39,23 @@ assert_not_contains() { fi } +first_state_file() { + find "$1" -name '*.pane' -type f -print | sort | sed -n '1p' +} + +session_from_herdr_log() { + sed -n 's/.*--env GHZINGA_SESSION=\([^ ]*\).*/\1/p' "$1" | tail -n 1 +} + +assert_different() { + left=$1 + right=$2 + if [ "$left" = "$right" ]; then + printf 'expected values to differ: %s\n' "$left" >&2 + exit 1 + fi +} + run_open() { url=$1 state=$2 @@ -58,7 +73,7 @@ run_open() { GZG_FAKE_LOG="$gzg_log" \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ "$@" \ - sh "${plugin_dir}/open.sh" >/dev/null + "$gzg_bin" herdr-plugin open >/dev/null } first_state="${work_dir}/first-state" @@ -66,14 +81,14 @@ mkdir -p "$first_state" first_herdr="${work_dir}/first-herdr.log" first_gzg="${work_dir}/first-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/29" "$first_state" "$first_herdr" "$first_gzg" +herdr_session=$(session_from_herdr_log "$first_herdr") assert_contains "$first_herdr" "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split --target-pane w1:p1 --direction right" assert_contains "$first_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/pull/29" assert_contains "$first_herdr" "--env GHZINGA_SESSION=$herdr_session" assert_contains "$first_herdr" "--env GHZINGA_BIN=${script_dir}/fake-gzg.sh" -assert_contains "$(state_file_for "$first_state")" "w1:p9" +assert_contains "$(first_state_file "$first_state")" "w1:p9" second_socket="${work_dir}/herdr-b.sock" -second_scope_key="socket_$(printf '%s\n' "$second_socket" | cksum | sed 's/[[:space:]].*//')" shared_state="${work_dir}/shared-state" mkdir -p "$shared_state" shared_first_herdr="${work_dir}/shared-first-herdr.log" @@ -85,9 +100,17 @@ run_open "https://github.com/dutifuldev/ghzinga/pull/30" "$shared_state" "$share run_open "https://github.com/dutifuldev/ghzinga/pull/31" "$shared_state" "$shared_second_herdr" "$shared_second_gzg" \ HERDR_SOCKET_PATH="$second_socket" \ HERDR_FAKE_OPENED_PANE=w1:p8 -assert_contains "${shared_state}/${herdr_scope_key}_w1_p1.pane" "w1:p9" -assert_contains "${shared_state}/${second_scope_key}_w1_p1.pane" "w1:p8" -assert_contains "$shared_second_herdr" "--env GHZINGA_SESSION=herdr-ghzinga-${second_scope_key}_w1_p1" +shared_first_session=$(session_from_herdr_log "$shared_first_herdr") +shared_second_session=$(session_from_herdr_log "$shared_second_herdr") +assert_different "$shared_first_session" "$shared_second_session" +assert_contains "$shared_second_herdr" "--env GHZINGA_SESSION=$shared_second_session" +if [ "$(find "$shared_state" -name '*.pane' -type f | wc -l | tr -d ' ')" != "2" ]; then + printf 'expected two scoped state files\n' >&2 + find "$shared_state" -name '*.pane' -type f -print >&2 + exit 1 +fi +grep -R -Fq 'w1:p9' "$shared_state" +grep -R -Fq 'w1:p8' "$shared_state" issue_state="${work_dir}/issue-state" mkdir -p "$issue_state" @@ -98,7 +121,11 @@ assert_contains "$issue_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuld reuse_state="${work_dir}/reuse-state" mkdir -p "$reuse_state" -printf 'w1:p9\n' >"$(state_file_for "$reuse_state")" +reuse_initial_herdr="${work_dir}/reuse-initial-herdr.log" +reuse_initial_gzg="${work_dir}/reuse-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/32" "$reuse_state" "$reuse_initial_herdr" "$reuse_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p9 +reuse_session=$(session_from_herdr_log "$reuse_initial_herdr") reuse_herdr="${work_dir}/reuse-herdr.log" reuse_gzg="${work_dir}/reuse-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" \ @@ -107,14 +134,18 @@ run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_ assert_contains "$reuse_herdr" "pane get w1:p9" assert_contains "$reuse_herdr" "plugin pane focus w1:p9" assert_not_contains "$reuse_herdr" "plugin pane open" -assert_contains "$reuse_gzg" "open --session $herdr_session https://github.com/dutifuldev/ghzinga/pull/33" +assert_contains "$reuse_gzg" "open --session $reuse_session https://github.com/dutifuldev/ghzinga/pull/33" fallback_bin="${work_dir}/fallback-bin" mkdir -p "$fallback_bin" ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" fallback_state="${work_dir}/fallback-state" mkdir -p "$fallback_state" -printf 'w1:p6\n' >"$(state_file_for "$fallback_state")" +fallback_initial_herdr="${work_dir}/fallback-initial-herdr.log" +fallback_initial_gzg="${work_dir}/fallback-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fallback_initial_herdr" "$fallback_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p6 +fallback_session=$(session_from_herdr_log "$fallback_initial_herdr") fallback_herdr="${work_dir}/fallback-herdr.log" fallback_gzg="${work_dir}/fallback-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fallback_herdr" "$fallback_gzg" \ @@ -122,11 +153,14 @@ run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fal GHZINGA_BIN= \ HERDR_FAKE_EXISTING_PANE=w1:p6 \ HERDR_FAKE_PLUGIN_PANE=w1:p6 -assert_contains "$fallback_gzg" "open --session $herdr_session https://github.com/dutifuldev/ghzinga/pull/36" +assert_contains "$fallback_gzg" "open --session $fallback_session https://github.com/dutifuldev/ghzinga/pull/36" stale_state="${work_dir}/stale-state" mkdir -p "$stale_state" -printf 'w1:p8\n' >"$(state_file_for "$stale_state")" +stale_initial_herdr="${work_dir}/stale-initial-herdr.log" +stale_initial_gzg="${work_dir}/stale-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_initial_herdr" "$stale_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p8 stale_herdr="${work_dir}/stale-herdr.log" stale_gzg="${work_dir}/stale-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" \ @@ -138,7 +172,10 @@ assert_not_contains "$stale_gzg" "open --session" other_plugin_state="${work_dir}/other-plugin-state" mkdir -p "$other_plugin_state" -printf 'w1:p7\n' >"$(state_file_for "$other_plugin_state")" +other_plugin_initial_herdr="${work_dir}/other-plugin-initial-herdr.log" +other_plugin_initial_gzg="${work_dir}/other-plugin-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_initial_herdr" "$other_plugin_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p7 other_plugin_herdr="${work_dir}/other-plugin-herdr.log" other_plugin_gzg="${work_dir}/other-plugin-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_herdr" "$other_plugin_gzg" \ @@ -158,7 +195,7 @@ if env \ HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ HERDR_FAKE_LOG="${work_dir}/invalid-herdr.log" \ GZG_FAKE_LOG="${work_dir}/invalid-gzg.log" \ - sh "${plugin_dir}/open.sh" 2>"$invalid_err"; then + "$gzg_bin" herdr-plugin open 2>"$invalid_err"; then printf 'expected invalid URL to fail\n' >&2 exit 1 fi @@ -171,7 +208,7 @@ if env \ HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ HERDR_FAKE_LOG="${work_dir}/missing-herdr.log" \ GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ - sh "${plugin_dir}/open.sh" 2>"$missing_err"; then + "$gzg_bin" herdr-plugin open 2>"$missing_err"; then printf 'expected missing URL to fail\n' >&2 exit 1 fi diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh index f92a3f70..815b5128 100755 --- a/plugins/herdr/test/test-viewer.sh +++ b/plugins/herdr/test/test-viewer.sh @@ -3,6 +3,8 @@ set -eu script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +repo_root=$(CDPATH= cd -- "${plugin_dir}/../.." && pwd) +gzg_bin=${GHZINGA_TEST_BIN:-${repo_root}/target/debug/gzg} work_dir=$(mktemp -d) cleanup() { @@ -10,6 +12,8 @@ cleanup() { } trap cleanup EXIT INT TERM +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null + assert_contains() { file=$1 expected=$2 @@ -27,26 +31,14 @@ env \ GHZINGA_SESSION="herdr-ghzinga-w1_p1" \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ GZG_FAKE_LOG="$gzg_log" \ - sh "${plugin_dir}/viewer.sh" >/dev/null + "$gzg_bin" herdr-plugin viewer >/dev/null assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 https://github.com/dutifuldev/ghzinga/pull/29" -fallback_bin="${work_dir}/fallback-bin" -mkdir -p "$fallback_bin" -ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" -fallback_log="${work_dir}/fallback-gzg.log" -env \ - PATH="$fallback_bin:/usr/bin:/bin" \ - GHZINGA_TARGET="https://github.com/dutifuldev/ghzinga/pull/30" \ - GHZINGA_SESSION="herdr-ghzinga-fallback" \ - GZG_FAKE_LOG="$fallback_log" \ - sh "${plugin_dir}/viewer.sh" >/dev/null -assert_contains "$fallback_log" "--session herdr-ghzinga-fallback https://github.com/dutifuldev/ghzinga/pull/30" - missing_err="${work_dir}/missing.err" if env \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ - sh "${plugin_dir}/viewer.sh" 2>"$missing_err"; then + "$gzg_bin" herdr-plugin viewer 2>"$missing_err"; then printf 'expected missing target to fail\n' >&2 exit 1 fi diff --git a/plugins/herdr/viewer.sh b/plugins/herdr/viewer.sh deleted file mode 100755 index 3cc0adc9..00000000 --- a/plugins/herdr/viewer.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env sh -set -eu - -ghzinga_bin() { - if [ -n "${GHZINGA_BIN:-}" ]; then - printf '%s\n' "$GHZINGA_BIN" - return - fi - if command -v gzg >/dev/null 2>&1; then - printf '%s\n' 'gzg' - return - fi - if command -v ghzinga >/dev/null 2>&1; then - printf '%s\n' 'ghzinga' - return - fi - printf '%s\n' 'gzg' -} - -target=${GHZINGA_TARGET:-} -[ -n "$target" ] || { - printf 'ghzinga-herdr: GHZINGA_TARGET is not set\n' >&2 - exit 1 -} - -session=${GHZINGA_SESSION:-herdr-ghzinga} -gzg=$(ghzinga_bin) - -exec "$gzg" --session "$session" "$target" diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 2b6a9780..46e48a3a 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -19,7 +19,7 @@ sh -n scripts/live-smoke.sh GZG_LIVE_SELF_TEST=1 scripts/live-smoke.sh sh -n scripts/herdr-plugin-live-smoke.sh HERDR_PLUGIN_LIVE_SELF_TEST=1 scripts/herdr-plugin-live-smoke.sh -for script in plugins/herdr/open.sh plugins/herdr/viewer.sh plugins/herdr/test/*.sh; do +for script in plugins/herdr/test/*.sh; do sh -n "$script" done plugins/herdr/test/test-open.sh diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh index b85fdf08..c332c0a2 100755 --- a/scripts/herdr-plugin-live-smoke.sh +++ b/scripts/herdr-plugin-live-smoke.sh @@ -63,6 +63,7 @@ def clean_env(extra=None): ): env.pop(key, None) env.setdefault("TERM", "xterm-256color") + env["PATH"] = f"{REPO / 'target' / 'debug'}:{env.get('PATH', '')}" env.update(ISOLATED_ENV) if extra: env.update(extra) @@ -201,6 +202,9 @@ def main(): write_executable( gzg_wrapper, "#!/bin/sh\n" + "if [ \"${1:-}\" = open ]; then\n" + f" exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\"\n" + "fi\n" f"exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\" " f"--offline-fixture {str(REPO / 'fixtures' / 'pr-81834.json')!r} " "--no-restore --refresh-seconds 0\n", @@ -237,7 +241,7 @@ def main(): } ) opened = subprocess.run( - ["sh", str(REPO / "plugins" / "herdr" / "open.sh")], + [str(REPO / "target" / "debug" / "gzg"), "herdr-plugin", "open"], cwd=REPO, env=action_env, text=True, diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs new file mode 100644 index 00000000..262dd820 --- /dev/null +++ b/src/herdr_plugin.rs @@ -0,0 +1,580 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command as StdCommand, +}; + +use anyhow::Context; +use serde_json::Value; + +use crate::domain::ResourceId; + +const DEFAULT_PLUGIN_ID: &str = "dutifuldev.ghzinga"; +const VIEWER_ENTRYPOINT: &str = "viewer"; + +pub fn run_command(args: &[String]) -> anyhow::Result { + let Some(command) = args.first().map(String::as_str) else { + print_usage_to_stderr(); + return Ok(2); + }; + + match command { + "open" => run_open_entrypoint(), + "viewer" => run_viewer_entrypoint(), + "help" | "--help" | "-h" => { + print_usage(); + Ok(0) + } + _ => { + print_usage_to_stderr(); + Ok(2) + } + } +} + +fn run_open_entrypoint() -> anyhow::Result { + let clicked_url = required_env("HERDR_PLUGIN_CLICKED_URL")?; + let source_pane = required_env("HERDR_PANE_ID")?; + let target = normalize_github_issue_or_pr_url(&clicked_url)?; + let herdr = env::var_os("HERDR_BIN_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("herdr")); + let plugin_id = env::var("HERDR_PLUGIN_ID").unwrap_or_else(|_| DEFAULT_PLUGIN_ID.into()); + let state_dir = plugin_state_dir(); + fs::create_dir_all(&state_dir) + .with_context(|| format!("failed to create {}", state_dir.display()))?; + + let source_key = herdr_source_key(&source_pane); + let session = format!("herdr-ghzinga-{source_key}"); + let state_file = state_dir.join(format!("{source_key}.pane")); + + if let Some(stored_pane) = read_stored_pane(&state_file)? { + if herdr_command_succeeds(&herdr, ["pane", "get", stored_pane.as_str()]) + && focus_is_our_viewer(&herdr, &stored_pane, &plugin_id)? + { + return run_ghzinga_open(&session, &target); + } + } + + let viewer_bin = ghzinga_viewer_bin(); + let response = run_herdr_capture( + &herdr, + vec![ + "plugin".into(), + "pane".into(), + "open".into(), + "--plugin".into(), + plugin_id, + "--entrypoint".into(), + VIEWER_ENTRYPOINT.into(), + "--placement".into(), + "split".into(), + "--target-pane".into(), + source_pane, + "--direction".into(), + "right".into(), + "--env".into(), + format!("GHZINGA_TARGET={target}"), + "--env".into(), + format!("GHZINGA_SESSION={session}"), + "--env".into(), + format!("GHZINGA_BIN={}", viewer_bin.display()), + "--focus".into(), + ], + )?; + print!("{response}"); + if !response.ends_with('\n') { + println!(); + } + + if let Some(opened_pane) = plugin_pane_id_from_response(&response) { + fs::write(&state_file, format!("{opened_pane}\n")) + .with_context(|| format!("failed to write {}", state_file.display()))?; + } else { + eprintln!("ghzinga-herdr: warning: could not find opened pane id in Herdr response"); + } + + Ok(0) +} + +fn run_viewer_entrypoint() -> anyhow::Result { + let target = required_env("GHZINGA_TARGET")?; + let session = env::var("GHZINGA_SESSION").unwrap_or_else(|_| "herdr-ghzinga".into()); + let bin = ghzinga_viewer_bin(); + let mut command = StdCommand::new(&bin); + command.arg("--session").arg(&session).arg(&target); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = command.exec(); + Err(error).with_context(|| format!("failed to exec {}", bin.display())) + } + + #[cfg(not(unix))] + { + let status = command + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) + } +} + +fn run_ghzinga_open(session: &str, target: &str) -> anyhow::Result { + let bin = ghzinga_control_bin(); + let status = StdCommand::new(&bin) + .arg("open") + .arg("--session") + .arg(session) + .arg(target) + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) +} + +fn required_env(name: &str) -> anyhow::Result { + let value = env::var(name).with_context(|| format!("{name} is not set"))?; + if value.is_empty() { + anyhow::bail!("{name} is not set"); + } + Ok(value) +} + +fn normalize_github_issue_or_pr_url(input: &str) -> anyhow::Result { + let trimmed = input.trim(); + if !trimmed.starts_with("https://github.com/") { + anyhow::bail!("unsupported GitHub issue/PR URL: {input}"); + } + let id = ResourceId::parse(trimmed) + .map_err(|_| anyhow::anyhow!("unsupported GitHub issue/PR URL: {input}"))?; + let kind = id + .kind_hint + .ok_or_else(|| anyhow::anyhow!("unsupported GitHub issue/PR URL: {input}"))?; + Ok(id.web_url_for_kind(kind)) +} + +fn plugin_state_dir() -> PathBuf { + env::var_os("HERDR_PLUGIN_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| env::temp_dir().join("ghzinga-herdr-plugin")) +} + +fn herdr_source_key(source_pane: &str) -> String { + format!("{}_{}", herdr_scope_key(), state_key_for_pane(source_pane)) +} + +fn herdr_scope_key() -> String { + if let Ok(socket) = env::var("HERDR_SOCKET_PATH") { + if !socket.is_empty() { + return format!("socket_{}", stable_key_for_text(&socket)); + } + } + if let Ok(session) = env::var("HERDR_SESSION") { + if !session.is_empty() { + return format!("session_{}", stable_key_for_text(&session)); + } + } + "default".into() +} + +fn state_key_for_pane(pane_id: &str) -> String { + pane_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect() +} + +fn stable_key_for_text(input: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn read_stored_pane(path: &Path) -> anyhow::Result> { + match fs::read_to_string(path) { + Ok(raw) => Ok(raw + .lines() + .next() + .map(str::trim) + .filter(|pane| !pane.is_empty()) + .map(str::to_string)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())), + } +} + +fn focus_is_our_viewer(herdr: &Path, pane_id: &str, plugin_id: &str) -> anyhow::Result { + let output = StdCommand::new(herdr) + .arg("plugin") + .arg("pane") + .arg("focus") + .arg(pane_id) + .output() + .with_context(|| format!("failed to run {}", herdr.display()))?; + if !output.status.success() { + return Ok(false); + } + let response = String::from_utf8_lossy(&output.stdout); + Ok(plugin_focuses_viewer(&response, plugin_id)) +} + +fn herdr_command_succeeds<'a>(herdr: &Path, args: impl IntoIterator) -> bool { + StdCommand::new(herdr) + .args(args) + .status() + .is_ok_and(|status| status.success()) +} + +fn run_herdr_capture(herdr: &Path, args: Vec) -> anyhow::Result { + let output = StdCommand::new(herdr) + .args(&args) + .output() + .with_context(|| format!("failed to run {}", herdr.display()))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if output.status.success() { + return Ok(stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + anyhow::bail!( + "herdr {} failed: {}", + args.join(" "), + if detail.is_empty() { + "no output" + } else { + &detail + } + ); +} + +fn plugin_focuses_viewer(response: &str, plugin_id: &str) -> bool { + let Ok(value) = serde_json::from_str::(response) else { + return false; + }; + value + .pointer("/result/plugin_pane/plugin_id") + .and_then(Value::as_str) + == Some(plugin_id) + && value + .pointer("/result/plugin_pane/entrypoint") + .and_then(Value::as_str) + == Some(VIEWER_ENTRYPOINT) +} + +fn plugin_pane_id_from_response(response: &str) -> Option { + let value = serde_json::from_str::(response).ok()?; + value + .pointer("/result/plugin_pane/pane/pane_id") + .or_else(|| value.pointer("/result/plugin_pane/pane_id")) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn ghzinga_viewer_bin() -> PathBuf { + env_path("GHZINGA_BIN") + .or_else(|| env::current_exe().ok()) + .or_else(|| command_on_path("gzg")) + .or_else(|| command_on_path("ghzinga")) + .unwrap_or_else(|| PathBuf::from("gzg")) +} + +fn ghzinga_control_bin() -> PathBuf { + env_path("GHZINGA_BIN") + .or_else(|| command_on_path("gzg")) + .or_else(|| command_on_path("ghzinga")) + .or_else(|| env::current_exe().ok()) + .unwrap_or_else(|| PathBuf::from("gzg")) +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn command_on_path(command: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|path| path.join(command)) + .find(|candidate| candidate.is_file()) +} + +fn print_usage() { + println!("usage: gzg herdr-plugin "); +} + +fn print_usage_to_stderr() { + eprintln!("usage: gzg herdr-plugin "); +} + +#[cfg(test)] +mod tests { + use std::{env, ffi::OsString, fs, path::PathBuf, sync::Mutex}; + + use super::{ + herdr_source_key, normalize_github_issue_or_pr_url, plugin_focuses_viewer, + plugin_pane_id_from_response, run_open_entrypoint, stable_key_for_text, state_key_for_pane, + }; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PLUGIN_ENV_KEYS: &[&str] = &[ + "GZG_FAKE_LOG", + "GHZINGA_BIN", + "HERDR_BIN_PATH", + "HERDR_FAKE_EXISTING_PANE", + "HERDR_FAKE_LOG", + "HERDR_FAKE_OPENED_PANE", + "HERDR_FAKE_PLUGIN_ENTRYPOINT", + "HERDR_FAKE_PLUGIN_ID", + "HERDR_FAKE_PLUGIN_PANE", + "HERDR_PANE_ID", + "HERDR_PLUGIN_CLICKED_URL", + "HERDR_PLUGIN_ID", + "HERDR_PLUGIN_STATE_DIR", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + ]; + + struct EnvRestore { + values: Vec<(&'static str, Option)>, + } + + impl EnvRestore { + fn clear() -> Self { + let values = PLUGIN_ENV_KEYS + .iter() + .map(|key| { + let value = env::var_os(key); + env::remove_var(key); + (*key, value) + }) + .collect(); + Self { values } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + for (key, value) in &self.values { + if let Some(value) = value { + env::set_var(key, value); + } else { + env::remove_var(key); + } + } + } + } + + #[test] + fn normalize_github_issue_or_pr_url_strips_suffixes_and_preserves_kind() { + assert_eq!( + normalize_github_issue_or_pr_url( + "https://github.com/dutifuldev/ghzinga/pull/29/files#diff" + ) + .unwrap(), + "https://github.com/dutifuldev/ghzinga/pull/29" + ); + assert_eq!( + normalize_github_issue_or_pr_url( + "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" + ) + .unwrap(), + "https://github.com/dutifuldev/ghzinga/issues/32" + ); + } + + #[test] + fn normalize_github_issue_or_pr_url_rejects_other_github_paths() { + let error = + normalize_github_issue_or_pr_url("https://github.com/dutifuldev/ghzinga/tree/main") + .unwrap_err(); + assert!(error + .to_string() + .contains("unsupported GitHub issue/PR URL")); + } + + #[test] + fn pane_and_scope_keys_are_stable_for_state_files() { + assert_eq!(state_key_for_pane("w1:p1"), "w1_p1"); + assert_eq!(state_key_for_pane("tab/pane:3"), "tab_pane_3"); + assert_eq!( + stable_key_for_text("/tmp/herdr-a.sock"), + stable_key_for_text("/tmp/herdr-a.sock") + ); + assert_ne!( + stable_key_for_text("/tmp/herdr-a.sock"), + stable_key_for_text("/tmp/herdr-b.sock") + ); + } + + #[test] + fn plugin_focus_validation_requires_matching_plugin_and_viewer() { + let response = r#"{"result":{"plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"w1:p9"}}}}"#; + assert!(plugin_focuses_viewer(response, "dutifuldev.ghzinga")); + assert!(!plugin_focuses_viewer(response, "example.other")); + + let wrong_entrypoint = r#"{"result":{"plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"other","pane":{"pane_id":"w1:p9"}}}}"#; + assert!(!plugin_focuses_viewer( + wrong_entrypoint, + "dutifuldev.ghzinga" + )); + } + + #[test] + fn plugin_pane_id_is_read_from_herdr_response() { + let response = r#"{"result":{"plugin_pane":{"pane":{"pane_id":"w1:p9"}}}}"#; + assert_eq!( + plugin_pane_id_from_response(response).as_deref(), + Some("w1:p9") + ); + } + + #[test] + fn open_entrypoint_opens_new_plugin_pane_and_records_state() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/29/files#diff", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains( + "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split" + )); + assert!(herdr_log + .contains("--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/pull/29")); + assert!(herdr_log.contains("--env GHZINGA_SESSION=herdr-ghzinga-socket_")); + assert!(herdr_log.contains("_w1_p1")); + + let state_file = fs::read_dir(&state_dir) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!(fs::read_to_string(state_file).unwrap(), "w1:p9\n"); + assert!(!gzg_log.exists()); + } + + #[test] + fn open_entrypoint_reuses_existing_matching_viewer_pane() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_EXISTING_PANE", "w1:p9"); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + let source_key = herdr_source_key("w1:p1"); + fs::write(state_dir.join(format!("{source_key}.pane")), "w1:p9\n").unwrap(); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("pane get w1:p9")); + assert!(herdr_log.contains("plugin pane focus w1:p9")); + assert!(!herdr_log.contains("plugin pane open")); + + let gzg_log = fs::read_to_string(gzg_log).unwrap(); + assert!(gzg_log.contains(&format!( + "open --session herdr-ghzinga-{source_key} https://github.com/dutifuldev/ghzinga/issues/32" + ))); + } + + #[test] + fn open_entrypoint_reopens_when_stored_pane_is_not_our_viewer() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/35", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_EXISTING_PANE", "w1:p7"); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p7"); + env::set_var("HERDR_FAKE_PLUGIN_ID", "example.other"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + let source_key = herdr_source_key("w1:p1"); + fs::write(state_dir.join(format!("{source_key}.pane")), "w1:p7\n").unwrap(); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("pane get w1:p7")); + assert!(herdr_log.contains("plugin pane focus w1:p7")); + assert!(herdr_log.contains("plugin pane open --plugin dutifuldev.ghzinga")); + assert!(!gzg_log.exists()); + } + + fn repo_file(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) + } +} diff --git a/src/lib.rs b/src/lib.rs index b05be1c7..ac50fe7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod control; pub mod domain; pub mod fetch; pub mod github; +mod herdr_plugin; pub mod input; pub mod render; pub mod runner; diff --git a/src/runner.rs b/src/runner.rs index 0bc8f1e3..062b9229 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -48,6 +48,7 @@ async fn maybe_run_session_command(args: &[String]) -> anyhow::Result run_session_subcommand(&args[2..]).map(Some), + "herdr-plugin" => crate::herdr_plugin::run_command(&args[2..]).map(Some), "open" => run_open_command(args, &args[2..]).await.map(Some), "set" => run_set_command(args, &args[2..]).await.map(Some), _ => Ok(None), From 5bef9a33deeae834d375c025e0c4def7212dfee2 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:28:32 +0800 Subject: [PATCH 08/14] test: refresh capture manifests --- captures/ghzinga-issue-88499/large/manifest.json | 4 ++-- captures/ghzinga-issue-88499/manifest.json | 4 ++-- captures/ghzinga-issue-88499/medium/manifest.json | 4 ++-- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-issue-88499/narrow/manifest.json | 4 ++-- captures/ghzinga-pr-81834/large/manifest.json | 4 ++-- captures/ghzinga-pr-81834/manifest.json | 4 ++-- captures/ghzinga-pr-81834/medium/manifest.json | 4 ++-- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-pr-81834/narrow/manifest.json | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 1103795e..43c9b833 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 7757f9fa..257c19b9 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index cbb9c0a8..9784b394 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 4a5d69fc..04b3e260 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -120,5 +120,5 @@ "ansi": "70_mouse_quit_confirm.ansi" } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index a27a0fa3..16880519 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index c26d8bea..03f52ffa 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index cda0becc..10334687 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 93ea88ef..6cd1d471 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 31eff387..8791c7c0 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -240,5 +240,5 @@ ], "load_full_fixture": "captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json", "load_full_command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json --no-restore --refresh-seconds 0", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 139b183a..c82865ab 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" } From 71f9702af782280040191afd1eaf998f080796b3 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:35:54 +0800 Subject: [PATCH 09/14] docs: document Herdr plugin architecture --- README.md | 5 +++++ plugins/herdr/README.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/README.md b/README.md index 8d8e654c..78429695 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,11 @@ herdr plugin install dutifuldev/ghzinga/plugins/herdr Then Ctrl-click a GitHub issue or pull request URL inside Herdr. The plugin opens or reuses a right-side ghzinga pane next to the clicked pane. +The plugin is intentionally thin: Herdr calls `gzg herdr-plugin open/viewer`, +and ghzinga owns the URL parsing, pane reuse, and session control in Rust. See +[`plugins/herdr/README.md`](plugins/herdr/README.md) for the install and +architecture details. + ## Refresh `ghzinga` refreshes automatically every 300 seconds by default. Use diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index 65bbe858..3520f700 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -30,6 +30,33 @@ The plugin opens a right-side ghzinga pane next to the pane that contained the link. Later Ctrl-clicks from the same source pane in the same Herdr session reuse that side pane by running `gzg open --session ...`. +Herdr currently routes plugin link handlers through Ctrl-click. Plain left-click +link handling would need Herdr itself to expose that behavior to plugins. + +## How it works + +The plugin manifest is only wiring. It registers a GitHub issue/PR link handler +and points Herdr at two ghzinga-owned entrypoints: + +```text +herdr-plugin.toml -> gzg herdr-plugin open +herdr-plugin.toml -> gzg herdr-plugin viewer +``` + +`gzg herdr-plugin open` runs as the Herdr link action. It reads Herdr's clicked +URL and source pane environment, normalizes the GitHub issue or PR URL with +ghzinga's Rust parser, and opens a right-side Herdr plugin pane beside the source +pane. + +`gzg herdr-plugin viewer` runs inside that side pane. It starts the normal +ghzinga TUI for the selected issue or PR. + +The Rust entrypoint also keeps pane reuse state scoped by Herdr session/socket +and source pane. On later Ctrl-clicks from the same source pane, it focuses the +existing ghzinga pane if it is still alive and still belongs to this plugin, then +sends `gzg open --session ... ` into that ghzinga session. If the stored +pane is gone or belongs to something else, it opens a fresh side pane. + ## Requirements - Herdr 0.7.0 or newer. @@ -40,3 +67,14 @@ reuse that side pane by running `gzg open --session ...`. Set `GHZINGA_BIN` before launching Herdr if you need to use a non-default ghzinga binary path for the viewer process. Normal installs do not need this. + +## Verification + +The production path has three layers of coverage: + +- Rust unit tests for URL normalization, pane identity validation, session + scoping, and the `gzg herdr-plugin open` entrypoint. +- Fake Herdr shell tests for open, reuse, stale-pane, and viewer launch behavior. +- `scripts/herdr-plugin-live-smoke.sh`, which starts a disposable Herdr session, + links this plugin, opens a real right-side pane, and verifies ghzinga rendered + fixture content there. From 596cefe9928c2b31ed31a45f162f491443dbb0f2 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:44:49 +0800 Subject: [PATCH 10/14] fix: reuse Herdr ghzinga pane for internal links --- README.md | 3 +- plugins/herdr/README.md | 17 ++++-- plugins/herdr/test/test-open.sh | 8 +++ scripts/herdr-plugin-live-smoke.sh | 33 +++++++++++ src/herdr_plugin.rs | 95 +++++++++++++++++++++++++++--- 5 files changed, 142 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 78429695..3a7fe8a1 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,8 @@ herdr plugin install dutifuldev/ghzinga/plugins/herdr ``` Then Ctrl-click a GitHub issue or pull request URL inside Herdr. The plugin opens -or reuses a right-side ghzinga pane next to the clicked pane. +or reuses a right-side ghzinga pane next to the clicked pane. Links followed from +inside that ghzinga pane update the same pane. The plugin is intentionally thin: Herdr calls `gzg herdr-plugin open/viewer`, and ghzinga owns the URL parsing, pane reuse, and session control in Rust. See diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index 3520f700..70dca178 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -30,6 +30,9 @@ The plugin opens a right-side ghzinga pane next to the pane that contained the link. Later Ctrl-clicks from the same source pane in the same Herdr session reuse that side pane by running `gzg open --session ...`. +Ctrl-clicks from inside that ghzinga side pane also stay in the same ghzinga +pane, so following related issue/PR links does not create nested ghzinga panes. + Herdr currently routes plugin link handlers through Ctrl-click. Plain left-click link handling would need Herdr itself to expose that behavior to plugins. @@ -57,6 +60,10 @@ existing ghzinga pane if it is still alive and still belongs to this plugin, the sends `gzg open --session ... ` into that ghzinga session. If the stored pane is gone or belongs to something else, it opens a fresh side pane. +When the source pane is already a ghzinga plugin viewer, the entrypoint uses the +viewer-pane-to-session state written at pane creation time and opens the link in +that same ghzinga session. + ## Requirements - Herdr 0.7.0 or newer. @@ -73,8 +80,10 @@ ghzinga binary path for the viewer process. Normal installs do not need this. The production path has three layers of coverage: - Rust unit tests for URL normalization, pane identity validation, session - scoping, and the `gzg herdr-plugin open` entrypoint. -- Fake Herdr shell tests for open, reuse, stale-pane, and viewer launch behavior. + scoping, self-pane reuse, and the `gzg herdr-plugin open` entrypoint. +- Fake Herdr shell tests for open, reuse, self-pane reuse, stale-pane, and viewer + launch behavior. - `scripts/herdr-plugin-live-smoke.sh`, which starts a disposable Herdr session, - links this plugin, opens a real right-side pane, and verifies ghzinga rendered - fixture content there. + links this plugin, opens a real right-side pane, verifies ghzinga rendered + fixture content there, and verifies links from the ghzinga pane reuse that + pane. diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index 455e8d7d..36497c3a 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -126,6 +126,14 @@ reuse_initial_gzg="${work_dir}/reuse-initial-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/32" "$reuse_state" "$reuse_initial_herdr" "$reuse_initial_gzg" \ HERDR_FAKE_OPENED_PANE=w1:p9 reuse_session=$(session_from_herdr_log "$reuse_initial_herdr") +self_herdr="${work_dir}/self-herdr.log" +self_gzg="${work_dir}/self-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/37" "$reuse_state" "$self_herdr" "$self_gzg" \ + HERDR_PANE_ID=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 +assert_contains "$self_herdr" "plugin pane focus w1:p9" +assert_not_contains "$self_herdr" "plugin pane open" +assert_contains "$self_gzg" "open --session $reuse_session https://github.com/dutifuldev/ghzinga/issues/37" reuse_herdr="${work_dir}/reuse-herdr.log" reuse_gzg="${work_dir}/reuse-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" \ diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh index c332c0a2..f33de31a 100755 --- a/scripts/herdr-plugin-live-smoke.sh +++ b/scripts/herdr-plugin-live-smoke.sh @@ -266,8 +266,41 @@ def main(): if "openclaw" not in visible.lower(): raise RuntimeError(f"ghzinga pane did not show the expected fixture:\n{visible}") + pane_count = len(panes) + self_env = clean_env( + { + "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, + "HERDR_PLUGIN_CLICKED_URL": TARGET_URL, + "HERDR_PANE_ID": neighbor_pane, + "HERDR_SOCKET_PATH": herdr_socket_path(), + "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", + "HERDR_PLUGIN_STATE_DIR": str(state_dir), + "HERDR_BIN_PATH": str(herdr_wrapper), + "GHZINGA_BIN": str(gzg_wrapper), + } + ) + self_opened = subprocess.run( + [str(REPO / "target" / "debug" / "gzg"), "herdr-plugin", "open"], + cwd=REPO, + env=self_env, + text=True, + capture_output=True, + timeout=30, + ) + if self_opened.returncode != 0: + detail = self_opened.stderr.strip() or self_opened.stdout.strip() + raise RuntimeError(f"plugin self-open entrypoint failed: {detail}") + panes_after_self_open = wait_for_panes(fd, count=pane_count, timeout=10) + if len(panes_after_self_open) != pane_count: + raise RuntimeError( + "ghzinga viewer link opened a nested plugin pane; " + f"before={pane_count}, after={len(panes_after_self_open)}" + ) + wait_for_visible(neighbor_pane, ["Overview", "Activity", "Files"], timeout=15) + print(f"OK: linked Herdr plugin {link['plugin']['plugin_id']}") print(f"OK: source pane {source_pane} opened right-side ghzinga pane {neighbor_pane}") + print(f"OK: ghzinga pane {neighbor_pane} reused itself for an internal link") print(f"OK: ghzinga rendered fixture content for {TARGET_URL}") finally: if child_pid is not None: diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs index 262dd820..4d1ba8a5 100644 --- a/src/herdr_plugin.rs +++ b/src/herdr_plugin.rs @@ -44,11 +44,17 @@ fn run_open_entrypoint() -> anyhow::Result { fs::create_dir_all(&state_dir) .with_context(|| format!("failed to create {}", state_dir.display()))?; + if let Some(session) = read_stored_line(&viewer_session_file(&state_dir, &source_pane))? { + if focus_is_our_viewer(&herdr, &source_pane, &plugin_id)? { + return run_ghzinga_open(&session, &target); + } + } + let source_key = herdr_source_key(&source_pane); let session = format!("herdr-ghzinga-{source_key}"); let state_file = state_dir.join(format!("{source_key}.pane")); - if let Some(stored_pane) = read_stored_pane(&state_file)? { + if let Some(stored_pane) = read_stored_line(&state_file)? { if herdr_command_succeeds(&herdr, ["pane", "get", stored_pane.as_str()]) && focus_is_our_viewer(&herdr, &stored_pane, &plugin_id)? { @@ -90,6 +96,9 @@ fn run_open_entrypoint() -> anyhow::Result { if let Some(opened_pane) = plugin_pane_id_from_response(&response) { fs::write(&state_file, format!("{opened_pane}\n")) .with_context(|| format!("failed to write {}", state_file.display()))?; + let session_file = viewer_session_file(&state_dir, &opened_pane); + fs::write(&session_file, format!("{session}\n")) + .with_context(|| format!("failed to write {}", session_file.display()))?; } else { eprintln!("ghzinga-herdr: warning: could not find opened pane id in Herdr response"); } @@ -159,6 +168,10 @@ fn plugin_state_dir() -> PathBuf { .unwrap_or_else(|| env::temp_dir().join("ghzinga-herdr-plugin")) } +fn viewer_session_file(state_dir: &Path, pane_id: &str) -> PathBuf { + state_dir.join(format!("{}.session", herdr_source_key(pane_id))) +} + fn herdr_source_key(source_pane: &str) -> String { format!("{}_{}", herdr_scope_key(), state_key_for_pane(source_pane)) } @@ -199,7 +212,7 @@ fn stable_key_for_text(input: &str) -> String { format!("{hash:016x}") } -fn read_stored_pane(path: &Path) -> anyhow::Result> { +fn read_stored_line(path: &Path) -> anyhow::Result> { match fs::read_to_string(path) { Ok(raw) => Ok(raw .lines() @@ -349,6 +362,16 @@ mod tests { "HERDR_SOCKET_PATH", ]; + fn first_file_with_extension(dir: &std::path::Path, extension: &str) -> PathBuf { + let mut files = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some(extension)) + .collect::>(); + files.sort(); + files.into_iter().next().unwrap() + } + struct EnvRestore { values: Vec<(&'static str, Option)>, } @@ -479,13 +502,15 @@ mod tests { assert!(herdr_log.contains("--env GHZINGA_SESSION=herdr-ghzinga-socket_")); assert!(herdr_log.contains("_w1_p1")); - let state_file = fs::read_dir(&state_dir) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - assert_eq!(fs::read_to_string(state_file).unwrap(), "w1:p9\n"); + assert_eq!( + fs::read_to_string(first_file_with_extension(&state_dir, "pane")).unwrap(), + "w1:p9\n" + ); + assert!( + fs::read_to_string(first_file_with_extension(&state_dir, "session")) + .unwrap() + .starts_with("herdr-ghzinga-socket_") + ); assert!(!gzg_log.exists()); } @@ -533,6 +558,58 @@ mod tests { ))); } + #[test] + fn open_entrypoint_reuses_current_viewer_when_link_originates_inside_ghzinga() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let initial_herdr_log = temp.path().join("initial-herdr.log"); + let initial_gzg_log = temp.path().join("initial-gzg.log"); + let self_herdr_log = temp.path().join("self-herdr.log"); + let self_gzg_log = temp.path().join("self-gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/29", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &initial_herdr_log); + env::set_var("HERDR_FAKE_OPENED_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &initial_gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/issues/32", + ); + env::set_var("HERDR_PANE_ID", "w1:p9"); + env::set_var("HERDR_FAKE_LOG", &self_herdr_log); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GZG_FAKE_LOG", &self_gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let source_key = herdr_source_key("w1:p1"); + let self_herdr_log = fs::read_to_string(self_herdr_log).unwrap(); + assert!(self_herdr_log.contains("plugin pane focus w1:p9")); + assert!(!self_herdr_log.contains("plugin pane open")); + + let self_gzg_log = fs::read_to_string(self_gzg_log).unwrap(); + assert!(self_gzg_log.contains(&format!( + "open --session herdr-ghzinga-{source_key} https://github.com/dutifuldev/ghzinga/issues/32" + ))); + } + #[test] fn open_entrypoint_reopens_when_stored_pane_is_not_our_viewer() { let _lock = ENV_LOCK.lock().unwrap(); From 011e83fdcb41446e6f19685ed0f866ceb3e77f96 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:50:47 +0800 Subject: [PATCH 11/14] fix: reuse existing Herdr viewer panes --- plugins/herdr/README.md | 4 ++- plugins/herdr/test/test-open.sh | 12 ++++++++ src/herdr_plugin.rs | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index 70dca178..c4a89076 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -62,7 +62,9 @@ pane is gone or belongs to something else, it opens a fresh side pane. When the source pane is already a ghzinga plugin viewer, the entrypoint uses the viewer-pane-to-session state written at pane creation time and opens the link in -that same ghzinga session. +that same ghzinga session. If that reverse state is missing, for example for a +pane opened by an older plugin build, it falls back to ghzinga's Herdr pane +context resolution and still updates the current viewer. ## Requirements diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh index 36497c3a..95ba8de3 100755 --- a/plugins/herdr/test/test-open.sh +++ b/plugins/herdr/test/test-open.sh @@ -119,6 +119,18 @@ issue_gzg="${work_dir}/issue-gzg.log" run_open "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" "$issue_state" "$issue_herdr" "$issue_gzg" assert_contains "$issue_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/issues/32" +legacy_viewer_state="${work_dir}/legacy-viewer-state" +mkdir -p "$legacy_viewer_state" +legacy_viewer_herdr="${work_dir}/legacy-viewer-herdr.log" +legacy_viewer_gzg="${work_dir}/legacy-viewer-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/38" "$legacy_viewer_state" "$legacy_viewer_herdr" "$legacy_viewer_gzg" \ + HERDR_PANE_ID=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 +assert_contains "$legacy_viewer_herdr" "plugin pane focus w1:p9" +assert_not_contains "$legacy_viewer_herdr" "plugin pane open" +assert_contains "$legacy_viewer_gzg" "open https://github.com/dutifuldev/ghzinga/pull/38" +assert_not_contains "$legacy_viewer_gzg" "--session" + reuse_state="${work_dir}/reuse-state" mkdir -p "$reuse_state" reuse_initial_herdr="${work_dir}/reuse-initial-herdr.log" diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs index 4d1ba8a5..ecdda047 100644 --- a/src/herdr_plugin.rs +++ b/src/herdr_plugin.rs @@ -48,6 +48,8 @@ fn run_open_entrypoint() -> anyhow::Result { if focus_is_our_viewer(&herdr, &source_pane, &plugin_id)? { return run_ghzinga_open(&session, &target); } + } else if focus_is_our_viewer(&herdr, &source_pane, &plugin_id)? { + return run_ghzinga_open_for_current_context(&target); } let source_key = herdr_source_key(&source_pane); @@ -141,6 +143,16 @@ fn run_ghzinga_open(session: &str, target: &str) -> anyhow::Result { Ok(status.code().unwrap_or(1)) } +fn run_ghzinga_open_for_current_context(target: &str) -> anyhow::Result { + let bin = ghzinga_control_bin(); + let status = StdCommand::new(&bin) + .arg("open") + .arg(target) + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) +} + fn required_env(name: &str) -> anyhow::Result { let value = env::var(name).with_context(|| format!("{name} is not set"))?; if value.is_empty() { @@ -610,6 +622,43 @@ mod tests { ))); } + #[test] + fn open_entrypoint_uses_current_viewer_context_when_reverse_state_is_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/38", + ); + env::set_var("HERDR_PANE_ID", "w1:p9"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("plugin pane focus w1:p9")); + assert!(!herdr_log.contains("plugin pane open")); + + let gzg_log = fs::read_to_string(gzg_log).unwrap(); + assert!(gzg_log.contains("open https://github.com/dutifuldev/ghzinga/pull/38")); + assert!(!gzg_log.contains("--session")); + } + #[test] fn open_entrypoint_reopens_when_stored_pane_is_not_our_viewer() { let _lock = ENV_LOCK.lock().unwrap(); From 1e84a126d091a3de891af4e66129c7f5d17f120a Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:03:33 +0800 Subject: [PATCH 12/14] fix: start Herdr viewer with fresh session state --- plugins/herdr/README.md | 6 +++++- plugins/herdr/test/test-viewer.sh | 2 +- scripts/herdr-plugin-live-smoke.sh | 16 ++++++++++++---- src/herdr_plugin.rs | 6 +++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index c4a89076..57ab7849 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -32,6 +32,8 @@ reuse that side pane by running `gzg open --session ...`. Ctrl-clicks from inside that ghzinga side pane also stay in the same ghzinga pane, so following related issue/PR links does not create nested ghzinga panes. +When a new ghzinga side pane is created, it starts from the clicked resource +rather than restoring tabs from a previous closed plugin pane. Herdr currently routes plugin link handlers through Ctrl-click. Plain left-click link handling would need Herdr itself to expose that behavior to plugins. @@ -52,7 +54,9 @@ ghzinga's Rust parser, and opens a right-side Herdr plugin pane beside the sourc pane. `gzg herdr-plugin viewer` runs inside that side pane. It starts the normal -ghzinga TUI for the selected issue or PR. +ghzinga TUI for the selected issue or PR with `--new --session`, so the pane has +a fresh initial resource while still exposing a stable session for later +`gzg open --session ...` updates. The Rust entrypoint also keeps pane reuse state scoped by Herdr session/socket and source pane. On later Ctrl-clicks from the same source pane, it focuses the diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh index 815b5128..c911e4e8 100755 --- a/plugins/herdr/test/test-viewer.sh +++ b/plugins/herdr/test/test-viewer.sh @@ -32,7 +32,7 @@ env \ GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ GZG_FAKE_LOG="$gzg_log" \ "$gzg_bin" herdr-plugin viewer >/dev/null -assert_contains "$gzg_log" "--session herdr-ghzinga-w1_p1 https://github.com/dutifuldev/ghzinga/pull/29" +assert_contains "$gzg_log" "--new --session herdr-ghzinga-w1_p1 https://github.com/dutifuldev/ghzinga/pull/29" missing_err="${work_dir}/missing.err" if env \ diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh index f33de31a..be2920e3 100755 --- a/scripts/herdr-plugin-live-smoke.sh +++ b/scripts/herdr-plugin-live-smoke.sh @@ -4,6 +4,7 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) session=${HERDR_PLUGIN_LIVE_SESSION:-gzgplug} target_url=${HERDR_PLUGIN_LIVE_URL:-https://github.com/openclaw/openclaw/pull/81834} +internal_url=${HERDR_PLUGIN_LIVE_INTERNAL_URL:-https://github.com/openclaw/openclaw/issues/88499} if [ "${HERDR_PLUGIN_LIVE_SELF_TEST:-0}" = "1" ]; then printf 'OK: herdr plugin live smoke self-test passed.\n' @@ -24,6 +25,7 @@ cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null HERDR_PLUGIN_LIVE_REPO_ROOT=$repo_root \ HERDR_PLUGIN_LIVE_SESSION_NAME=$session \ HERDR_PLUGIN_LIVE_TARGET_URL=$target_url \ +HERDR_PLUGIN_LIVE_INTERNAL_URL=$internal_url \ python3 - <<'PY' import json import os @@ -45,6 +47,7 @@ import termios REPO = Path(os.environ["HERDR_PLUGIN_LIVE_REPO_ROOT"]) SESSION = os.environ["HERDR_PLUGIN_LIVE_SESSION_NAME"] TARGET_URL = os.environ["HERDR_PLUGIN_LIVE_TARGET_URL"] +INTERNAL_URL = os.environ["HERDR_PLUGIN_LIVE_INTERNAL_URL"] ROWS = 40 COLS = 140 ISOLATED_ENV = {} @@ -207,7 +210,8 @@ def main(): "fi\n" f"exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\" " f"--offline-fixture {str(REPO / 'fixtures' / 'pr-81834.json')!r} " - "--no-restore --refresh-seconds 0\n", + f"--offline-resource-fixture {str(REPO / 'fixtures' / 'issue-88499.json')!r} " + "--refresh-seconds 0\n", ) child_pid, fd = pty.fork() @@ -270,7 +274,7 @@ def main(): self_env = clean_env( { "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, - "HERDR_PLUGIN_CLICKED_URL": TARGET_URL, + "HERDR_PLUGIN_CLICKED_URL": INTERNAL_URL, "HERDR_PANE_ID": neighbor_pane, "HERDR_SOCKET_PATH": herdr_socket_path(), "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", @@ -296,11 +300,15 @@ def main(): "ghzinga viewer link opened a nested plugin pane; " f"before={pane_count}, after={len(panes_after_self_open)}" ) - wait_for_visible(neighbor_pane, ["Overview", "Activity", "Files"], timeout=15) + wait_for_visible( + neighbor_pane, + ["openai-responses provider", "Bug Description"], + timeout=15, + ) print(f"OK: linked Herdr plugin {link['plugin']['plugin_id']}") print(f"OK: source pane {source_pane} opened right-side ghzinga pane {neighbor_pane}") - print(f"OK: ghzinga pane {neighbor_pane} reused itself for an internal link") + print(f"OK: ghzinga pane {neighbor_pane} reused itself for {INTERNAL_URL}") print(f"OK: ghzinga rendered fixture content for {TARGET_URL}") finally: if child_pid is not None: diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs index ecdda047..997c0477 100644 --- a/src/herdr_plugin.rs +++ b/src/herdr_plugin.rs @@ -113,7 +113,11 @@ fn run_viewer_entrypoint() -> anyhow::Result { let session = env::var("GHZINGA_SESSION").unwrap_or_else(|_| "herdr-ghzinga".into()); let bin = ghzinga_viewer_bin(); let mut command = StdCommand::new(&bin); - command.arg("--session").arg(&session).arg(&target); + command + .arg("--new") + .arg("--session") + .arg(&session) + .arg(&target); #[cfg(unix)] { From 50adb66554e3eb8b6a53e751631532d968f2588b Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:26:28 +0800 Subject: [PATCH 13/14] chore: release 0.4.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 5 ++++- plugins/herdr/README.md | 35 ++++++++++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4330f39e..24179536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,7 +640,7 @@ dependencies = [ [[package]] name = "ghzinga" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 67cfdec7..85a85750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghzinga" -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.88" description = "Terminal UI for monitoring GitHub pull requests and issues." diff --git a/README.md b/README.md index 3a7fe8a1..95d266ad 100644 --- a/README.md +++ b/README.md @@ -228,13 +228,16 @@ from Herdr panes. Install it with: ```sh -herdr plugin install dutifuldev/ghzinga/plugins/herdr +cargo install ghzinga --locked --force +herdr plugin install dutifuldev/ghzinga/plugins/herdr --yes ``` Then Ctrl-click a GitHub issue or pull request URL inside Herdr. The plugin opens or reuses a right-side ghzinga pane next to the clicked pane. Links followed from inside that ghzinga pane update the same pane. +The Herdr plugin requires `ghzinga` 0.4.0 or newer on `PATH`. + The plugin is intentionally thin: Herdr calls `gzg herdr-plugin open/viewer`, and ghzinga owns the URL parsing, pane reuse, and session control in Rust. See [`plugins/herdr/README.md`](plugins/herdr/README.md) for the install and diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md index 57ab7849..d2d0704e 100644 --- a/plugins/herdr/README.md +++ b/plugins/herdr/README.md @@ -5,10 +5,16 @@ pane. ## Install -From the `ghzinga` repository: +Install `ghzinga` 0.4.0 or newer first: ```sh -herdr plugin install dutifuldev/ghzinga/plugins/herdr +cargo install ghzinga --locked --force +``` + +Then install the Herdr plugin: + +```sh +herdr plugin install dutifuldev/ghzinga/plugins/herdr --yes ``` For local development: @@ -73,7 +79,7 @@ context resolution and still updates the current viewer. ## Requirements - Herdr 0.7.0 or newer. -- `gzg` installed on `PATH`. The Herdr entrypoints call +- `ghzinga` 0.4.0 or newer, with `gzg` installed on `PATH`. The Herdr entrypoints call `gzg herdr-plugin open` and `gzg herdr-plugin viewer`. - GitHub credentials through `gh auth token`, `GH_TOKEN`, or `GITHUB_TOKEN` for private repositories. @@ -81,6 +87,29 @@ context resolution and still updates the current viewer. Set `GHZINGA_BIN` before launching Herdr if you need to use a non-default ghzinga binary path for the viewer process. Normal installs do not need this. +## Troubleshooting + +Check that Herdr can find a new enough ghzinga binary: + +```sh +gzg --version +``` + +Check that Herdr has the plugin installed and enabled: + +```sh +herdr plugin list --json +``` + +Inspect plugin command failures: + +```sh +herdr plugin log list --plugin dutifuldev.ghzinga +``` + +If Herdr still runs an older `gzg`, reinstall ghzinga and restart Herdr so its +plugin environment sees the updated `PATH`. + ## Verification The production path has three layers of coverage: From 7c80288079751df040a11c42e4d40411f8d56370 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:38:08 +0800 Subject: [PATCH 14/14] test: refresh capture manifest stamps --- captures/ghzinga-issue-88499/large/manifest.json | 4 ++-- captures/ghzinga-issue-88499/manifest.json | 4 ++-- captures/ghzinga-issue-88499/medium/manifest.json | 4 ++-- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-issue-88499/narrow/manifest.json | 4 ++-- captures/ghzinga-pr-81834/large/manifest.json | 4 ++-- captures/ghzinga-pr-81834/manifest.json | 4 ++-- captures/ghzinga-pr-81834/medium/manifest.json | 4 ++-- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-pr-81834/narrow/manifest.json | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 43c9b833..4eeaab5c 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 257c19b9..b4e9a2db 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index 9784b394..d6942f10 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 04b3e260..df007a44 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -120,5 +120,5 @@ "ansi": "70_mouse_quit_confirm.ansi" } ], - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index 16880519..eab5b541 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index 03f52ffa..833601b6 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index 10334687..c0101750 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 6cd1d471..9414e0d5 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 8791c7c0..55170071 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -240,5 +240,5 @@ ], "load_full_fixture": "captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json", "load_full_command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json --no-restore --refresh-seconds 0", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index c82865ab..eaebe346 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "39484e177d9fbad7988468d0077a46daccf7cf80", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "6ddfc7874710587f8f5ea27991ebfa279ef45bdef730e306f5923711aa3e8549" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" }