Initial release of herdr session switcher

This commit is contained in:
2026-09-02 01:33:38 +00:00
commit 6c2c9672e1
16 changed files with 3400 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.coverage
+10
View File
@@ -0,0 +1,10 @@
.PHONY: doctor install test
doctor:
./bin/herdr-hub doctor
install:
./scripts/install.sh
test:
python3 -m unittest discover -s tests -v
+211
View File
@@ -0,0 +1,211 @@
# herdr-hub
`herdr-hub` 是一个 Linux 下的 Herdr session 切换器。它让 tmux 做最外层路由,
把每个本地或 `herdr --remote` attach 放进独立 tmux window,并在 Herdr 画面顶部
显示可点击的 Sessions 条。
它不是 Herdr 插件,也不会修改 Herdr core。Herdr 0.8.2 的插件 API 不能向原生
sidebar 插入 UI,也不能让 thin client 从一个 server 原地改连另一个 server;
所以顶部状态栏横跨整个终端,而不是只嵌在 Spaces 上方。
## 功能
- 自动读取 `herdr session list --json`,导入本机正在运行的 sessions。
- 记住使用过的远端,并通过 SSH 实时读取运行中及已停止的 Herdr sessions。
- 在 Linux `/proc/*/cmdline` 中查找仍在运行的 `herdr --remote …` clients,并自动记住目标。
- 首次接管已有 remote client 时安全地再 attach,不结束原进程。
- 一个 endpoint 对应一个 tmux window,隐藏 window 中的连接继续在线。
- 顶部 Sessions 条在窄屏下也为每个 Tab 显示 Session 优先的短名称,并支持鼠标点击、数字切换和无需 fzf 的动态 popup 选择。
- 可隐藏不再需要的 Tab,而不停止底层本地或远端 Herdr Session。
- dead window 留在顶栏,可原地重连。
- 不修改 `~/.tmux.conf`,不保存 SSH 凭据或远端 Session 缓存。
## 依赖
- Linux
- Python 3.11+
- tmux 3.4+
- Herdr 0.8.2+
- OpenSSH client
检查当前环境:
```bash
./bin/herdr-hub doctor
```
## 安装
直接从仓库运行:
```bash
./bin/herdr-hub
```
或把启动器链接到 `~/.local/bin`:
```bash
make install
herdr-hub doctor
```
安装脚本只创建一个指向本仓库的符号链接。移动或删除仓库后需重新安装。
## 第一次接管
如果本地和 remote Herdr clients 已经分别开在其他终端中:
1. 保持这些 clients 运行。
2. 从一个新的、未嵌套在 tmux 中的终端执行 `herdr-hub`。
3. Hub 扫描现有 clients,并为每个 endpoint 创建一个新的 attach window。
4. 确认 Hub 中可以正常操作后,手动退出原来的 clients。
tmux 不能把另一个终端里已经运行的进程搬进 window,所以首次接管会短暂存在两个
clients;它们连接的是同一个 Herdr server,不会复制 server 或 panes。
只有一个终端时,本地 Herdr 可以先 detach,因为 `herdr session list` 仍能发现后台
server。remote target 在 client 退出后没有本地记录,因此直接使用:
```bash
herdr-hub open --remote orangepi
herdr-hub open --remote orangepi --session work
herdr-hub open --remote ssh://me@server:2222 --remote-keybindings server
```
打开本地 named session:
```bash
herdr-hub open --session work
```
`open` 会把 endpoint 保存在当前 tmux hub 的 window metadata 中。远端 target 还会
同时加入 `~/.config/herdr-hub/remotes.json`,以后可直接从 Sessions
弹窗浏览这台机器上的其他 Session。
## 日常操作
Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外层前缀固定为
`Ctrl+A`,Herdr 原来的 `Ctrl+B` 可以直接使用。
| 操作 | 快捷键 / 命令 |
| --- | --- |
| 点击切换 | 鼠标点击顶部 session |
| 打开 Session 弹窗 | 点击左上角 `Sessions` 或 `Ctrl+A g` |
| 上一个/下一个 window | `Ctrl+A p` / `Ctrl+A n` |
| 按编号切换 | `Ctrl+A 0..9` |
| 扫描新增 sessions | `Ctrl+A r` 或 `herdr-hub refresh` |
| 强制重连当前 window | `Ctrl+A R` |
| 关闭/隐藏当前 Tab | `Ctrl+A &`,确认 `y`;也可在 Sessions 弹窗中选择 |
| detach Hub | `Ctrl+A d` |
| 重新进入 Hub | `herdr-hub` |
| 停止 Hub clients | `herdr-hub stop` |
## 远端 Session 弹窗
弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的
window,同时在后台并行查询所有已登记远端;每台机器的结果就绪后原地更新。
状态与选择行为:
| 状态 | 选择后的行为 |
| --- | --- |
| `▶` 当前 / `●` 已打开 | 跳转到已有 window |
| `×` 已断线 | 自动重连并跳转 |
| `○` 远端运行 | 新建 attach window 并跳转 |
| `■` 远端已停止 | 启动该 Session、attach 并跳转 |
弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr
Session 和工作现场继续运行。每个远端下方都有“新建 Session”入口。弹窗底部还可添加或管理远端;移除远端只删除
记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行,
键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。
也可通过 CLI 管理远端:
```bash
herdr-hub remote list
herdr-hub remote add orangepi
herdr-hub remote add ssh://me@server:2222 --remote-keybindings server
herdr-hub remote remove orangepi
```
注册表默认位于 `~/.config/herdr-hub/remotes.json`(遵循 `$XDG_CONFIG_HOME`),权限为
`0600`。它只保存 SSH target 和 remote keybinding 模式。
## 复制与剪贴板
Hub 启用 tmux 的 OSC 52 剪贴板透传。Herdr 触发的自动复制,以及 tmux 接管后的
鼠标拖选,都会尝试写入当前 Hub 访问端的本地剪贴板。远程 session 中复制的
文本也回到访问端,不是远程机器的剪贴板。
访问端的终端需要支持并允许 OSC 52。如果终端或 Web terminal 禁止了这项能力,
可按住 `Shift` 再拖选,绕过 tmux 并使用终端原生复制。
当访问端宽度小于 80 列时,Hub 会自动进入窄屏模式:隐藏右侧快捷键说明,
所有 Tab 都显示状态和最多 12 列的短名称,并优先显示主机:默认 Session 的本地与
远端分别显示 `local`、`host`,非默认 Session 才显示 `local/work`、`host/work`。
宽度小于 50 列时左上角标题会缩成 `S`,继续给 Tab 留空间。点击 `Sessions`/`S`
可打开占 90% 屏幕的完整选择器。
`refresh` 是 add-only:它添加新发现的 endpoint,不自动删除已有 window,也不会改变
当前焦点。通过 `Ctrl+A &` 或弹窗关闭的 endpoint 会在当前 Hub 生命周期内记为已隐藏,
刷新和断开终端后重新进入都不会让它自动回来。要恢复它,打开 Sessions 弹窗并重新选择;
也可以执行对应的 `herdr-hub open` 命令。远端通过 SSH 目录重新选择,本地隐藏的运行中
Session 会列在“本机”下。Hub 至少保留一个 Tab;要全部退出请使用 `herdr-hub stop`。
当 endpoint 连接进程退出时,window 会保留:顶栏将它标记为 `× [断开]`,
当前 pane 底部会显示退出状态和 `Ctrl+A R` 重连提示。这与正常 detach Hub
不同;仅关闭外层终端后,直接再次执行 `herdr-hub` 即可。
`herdr-hub stop` 只结束 tmux 中的 client processes。本地及远端 Herdr servers 和
它们的 panes 会继续运行。
## CLI
```text
herdr-hub
herdr-hub start
herdr-hub refresh
herdr-hub open [--session NAME]
herdr-hub open --remote TARGET [--session NAME]
[--remote-keybindings local|server]
herdr-hub choose
herdr-hub remote list
herdr-hub remote add TARGET [--remote-keybindings local|server]
herdr-hub remote remove TARGET
herdr-hub reconnect [--window ID]
herdr-hub close [--window ID]
herdr-hub doctor
herdr-hub stop
```
## 自动发现与远端查询的边界
零配置的首次发现只覆盖:
- 当前用户正在运行的本地 Herdr servers;
- 当前用户、同一 PID namespace 中仍然存活的 `herdr --remote` processes。
发现或手动添加一次后,remote target 会被记住,不再要求本地 client 持续存活。弹窗使用
非交互 SSH 查询 `$HOME/.local/bin/herdr session list --json`;SSH key/passphrase 应已由
ssh-agent 处理。未知 host key、需要密码或远端不可达时会显示可重试的错误,不会卡住
整个弹窗。首次安装或 host-key 确认仍可在实际新建的 Herdr window 中交互完成。
首次使用且尚未登记时,以下情况仍无法自动找回 remote target:
- remote client 已经退出,但远端 server 仍在后台运行;
- client 位于另一个 container/PID namespace;
- `/proc` 使用了阻止读取同用户 argv 的安全配置;
- 可执行文件不叫 `herdr`。
这些场景可在弹窗中选择“添加远端”,或使用 `herdr-hub open --remote …`。为了避免嵌套 tmux 与前缀冲突,
`herdr-hub` 应从普通终端启动;若当前已经在另一个 tmux server 中,它会拒绝再次
attach。
## 开发与测试
```bash
make test
```
测试使用伪造的 `/proc` 和 Herdr executable;tmux 集成测试使用唯一 socket 和
`/dev/null` 配置,不接触真实 Herdr sessions 或现有 tmux server。
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Repository-local launcher for herdr_hub."""
from pathlib import Path
import sys
repository_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(repository_root))
from herdr_hub.cli import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
"""tmux-based session switcher for Herdr."""
__version__ = "0.2.0"
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+1210
View File
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
import fcntl
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
from typing import Iterable, Sequence
from urllib.parse import SplitResult, unquote, urlsplit
REGISTRY_VERSION = 1
REMOTE_LIST_SCRIPT = (
'if [ -x "$HOME/.local/bin/herdr" ]; then '
'exec "$HOME/.local/bin/herdr" session list --json; '
"elif command -v herdr >/dev/null 2>&1; then "
"exec herdr session list --json; "
"else echo 'herdr is not installed on the remote host' >&2; exit 127; fi"
)
class RegistryError(RuntimeError):
"""A remote registry error which is safe to show to the user."""
class RemoteTargetError(ValueError):
"""A remote target cannot be represented safely as an SSH command."""
@dataclass(frozen=True)
class RemoteEntry:
target: str
remote_keybindings: str = "local"
def __post_init__(self) -> None:
validate_target(self.target)
if self.remote_keybindings not in {"local", "server"}:
raise ValueError("remote keybindings must be 'local' or 'server'")
@dataclass(frozen=True)
class RemoteSession:
name: str
running: bool
@dataclass(frozen=True)
class RemoteQueryResult:
entry: RemoteEntry
sessions: tuple[RemoteSession, ...] = ()
error: str | None = None
def validate_target(target: str) -> None:
if not target:
raise RemoteTargetError("remote target cannot be empty")
if target.startswith("-"):
raise RemoteTargetError("remote target must not start with '-'")
if any(ord(character) < 32 or ord(character) == 127 for character in target):
raise RemoteTargetError("remote target must not contain control characters")
if target.startswith("ssh://"):
_parse_ssh_url(target)
def _parse_ssh_url(target: str) -> SplitResult:
parsed = urlsplit(target)
if parsed.scheme != "ssh" or parsed.hostname is None:
raise RemoteTargetError("invalid ssh:// remote target")
if parsed.password is not None:
raise RemoteTargetError("ssh:// targets must not contain passwords")
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise RemoteTargetError("ssh:// targets must not contain a path, query or fragment")
try:
parsed.port
except ValueError as error:
raise RemoteTargetError(f"ssh:// target has an invalid port: {error}") from error
host = parsed.hostname
if host.startswith("-") or any(character.isspace() for character in host):
raise RemoteTargetError("ssh:// target contains an invalid host")
if parsed.username is not None:
username = unquote(parsed.username)
if (
not username
or username.startswith("-")
or any(
character.isspace()
or ord(character) < 32
or ord(character) == 127
for character in username
)
):
raise RemoteTargetError("ssh:// target contains an invalid username")
return parsed
def default_registry_path() -> Path:
if override := os.environ.get("HERDR_HUB_REMOTES_FILE"):
return Path(override).expanduser()
config_home = os.environ.get("XDG_CONFIG_HOME")
root = Path(config_home).expanduser() if config_home else Path.home() / ".config"
return root / "herdr-hub" / "remotes.json"
class RemoteRegistry:
def __init__(self, path: Path | None = None) -> None:
self.path = path or default_registry_path()
@property
def lock_path(self) -> Path:
return self.path.with_name(f".{self.path.name}.lock")
def load(self) -> list[RemoteEntry]:
if not self.path.exists():
return []
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RegistryError(f"cannot read {self.path}: {error}") from error
return _parse_registry(payload, self.path)
def upsert(self, entry: RemoteEntry) -> bool:
def update(entries: list[RemoteEntry]) -> tuple[list[RemoteEntry], bool]:
by_target = {item.target: item for item in entries}
changed = by_target.get(entry.target) != entry
by_target[entry.target] = entry
return sorted(by_target.values(), key=lambda item: item.target), changed
return self._update(update)
def upsert_many(self, entries: Iterable[RemoteEntry]) -> int:
incoming = list(entries)
if not incoming:
return 0
def update(current: list[RemoteEntry]) -> tuple[list[RemoteEntry], int]:
by_target = {item.target: item for item in current}
changed = 0
for entry in incoming:
if by_target.get(entry.target) != entry:
changed += 1
by_target[entry.target] = entry
return sorted(by_target.values(), key=lambda item: item.target), changed
return self._update(update)
def remove(self, target: str) -> bool:
validate_target(target)
def update(entries: list[RemoteEntry]) -> tuple[list[RemoteEntry], bool]:
remaining = [entry for entry in entries if entry.target != target]
return remaining, len(remaining) != len(entries)
return self._update(update)
def _update(self, callback): # type: ignore[no-untyped-def]
try:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with self.lock_path.open("a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
entries = self.load()
updated, outcome = callback(entries)
if updated != entries:
self._write(updated)
return outcome
except RegistryError:
raise
except OSError as error:
raise RegistryError(f"cannot update {self.path}: {error}") from error
def _write(self, entries: Sequence[RemoteEntry]) -> None:
payload = {
"version": REGISTRY_VERSION,
"remotes": [
{
"target": entry.target,
"remote_keybindings": entry.remote_keybindings,
}
for entry in entries
],
}
temporary_name: str | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=self.path.parent,
prefix=f".{self.path.name}.",
delete=False,
) as temporary:
temporary_name = temporary.name
json.dump(payload, temporary, ensure_ascii=False, indent=2)
temporary.write("\n")
temporary.flush()
os.fsync(temporary.fileno())
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, self.path)
directory_fd = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if temporary_name is not None:
try:
Path(temporary_name).unlink(missing_ok=True)
except OSError:
pass
def _parse_registry(payload: object, path: Path) -> list[RemoteEntry]:
if not isinstance(payload, dict) or payload.get("version") != REGISTRY_VERSION:
raise RegistryError(f"{path} has an unsupported or missing version")
raw_entries = payload.get("remotes")
if not isinstance(raw_entries, list):
raise RegistryError(f"{path} does not contain a remotes list")
entries: list[RemoteEntry] = []
seen: set[str] = set()
for index, raw in enumerate(raw_entries):
if not isinstance(raw, dict):
raise RegistryError(f"{path}: remotes[{index}] is not an object")
target = raw.get("target")
keybindings = raw.get("remote_keybindings", "local")
if not isinstance(target, str) or not isinstance(keybindings, str):
raise RegistryError(f"{path}: remotes[{index}] has invalid fields")
try:
entry = RemoteEntry(target, keybindings)
except ValueError as error:
raise RegistryError(f"{path}: remotes[{index}]: {error}") from error
if entry.target in seen:
raise RegistryError(f"{path}: duplicate remote target {entry.target!r}")
seen.add(entry.target)
entries.append(entry)
return sorted(entries, key=lambda entry: entry.target)
def ssh_destination_arguments(target: str) -> list[str]:
validate_target(target)
if not target.startswith("ssh://"):
return [target]
parsed = _parse_ssh_url(target)
assert parsed.hostname is not None
host = parsed.hostname
arguments: list[str] = []
if parsed.port is not None:
arguments.extend(["-p", str(parsed.port)])
if parsed.username is not None:
username = unquote(parsed.username)
arguments.extend(["-l", username])
arguments.append(host)
return arguments
def remote_session_command(
ssh_bin: str,
entry: RemoteEntry,
*,
connect_timeout: int = 4,
) -> list[str]:
return [
ssh_bin,
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"ConnectionAttempts=1",
"-o",
f"ConnectTimeout={connect_timeout}",
*ssh_destination_arguments(entry.target),
REMOTE_LIST_SCRIPT,
]
def parse_remote_session_result(
entry: RemoteEntry,
returncode: int,
stdout: str,
stderr: str,
) -> RemoteQueryResult:
if returncode != 0:
detail = (stderr or stdout or f"ssh exited with status {returncode}").strip()
detail = " ".join(detail.splitlines())
if len(detail) > 180:
detail = detail[:179] + "…"
return RemoteQueryResult(entry, error=detail)
try:
payload = json.loads(stdout)
raw_sessions = payload["sessions"]
if not isinstance(raw_sessions, list):
raise TypeError("sessions is not a list")
except (json.JSONDecodeError, KeyError, TypeError) as error:
return RemoteQueryResult(entry, error=f"invalid Herdr session JSON: {error}")
sessions: dict[str, RemoteSession] = {}
for raw in raw_sessions:
if not isinstance(raw, dict):
continue
name = raw.get("name")
running = raw.get("running")
if isinstance(name, str) and isinstance(running, bool):
sessions.setdefault(name, RemoteSession(name, running))
ordered = sorted(
sessions.values(),
key=lambda session: (session.name != "default", session.name),
)
return RemoteQueryResult(entry, sessions=tuple(ordered))
@dataclass
class _RemoteJob:
entry: RemoteEntry
process: subprocess.Popen[str]
started_at: float
class RemoteQueryPool:
"""Pollable bounded pool of SSH processes for an interactive selector."""
def __init__(
self,
ssh_bin: str,
*,
max_parallel: int = 6,
query_timeout: float = 6.0,
connect_timeout: int = 4,
) -> None:
self.ssh_bin = ssh_bin
self.max_parallel = max_parallel
self.query_timeout = query_timeout
self.connect_timeout = connect_timeout
self._pending: deque[RemoteEntry] = deque()
self._active: dict[str, _RemoteJob] = {}
self._completed: deque[RemoteQueryResult] = deque()
def enqueue(self, entry: RemoteEntry) -> None:
self.cancel(entry.target)
self._pending.append(entry)
self._start_pending()
def enqueue_many(self, entries: Iterable[RemoteEntry]) -> None:
for entry in entries:
self.cancel(entry.target)
self._pending.append(entry)
self._start_pending()
def cancel(self, target: str) -> None:
self._pending = deque(entry for entry in self._pending if entry.target != target)
job = self._active.pop(target, None)
if job is not None:
self._stop_process(job.process)
self._start_pending()
def is_loading(self, target: str) -> bool:
return target in self._active or any(
entry.target == target for entry in self._pending
)
def poll(self) -> list[RemoteQueryResult]:
finished = list(self._completed)
self._completed.clear()
now = time.monotonic()
for target, job in list(self._active.items()):
returncode = job.process.poll()
timed_out = now - job.started_at >= self.query_timeout
if returncode is None and not timed_out:
continue
if timed_out and returncode is None:
job.process.kill()
stdout, stderr = job.process.communicate()
self._active.pop(target, None)
if timed_out:
finished.append(
RemoteQueryResult(
job.entry,
error=f"remote query timed out after {self.query_timeout:g}s",
)
)
else:
finished.append(
parse_remote_session_result(
job.entry,
job.process.returncode,
stdout,
stderr,
)
)
self._start_pending()
return finished
def close(self) -> None:
self._pending.clear()
for job in list(self._active.values()):
self._stop_process(job.process)
self._active.clear()
def _start_pending(self) -> None:
while self._pending and len(self._active) < self.max_parallel:
entry = self._pending.popleft()
try:
process = subprocess.Popen(
remote_session_command(
self.ssh_bin,
entry,
connect_timeout=self.connect_timeout,
),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except (OSError, ValueError) as error:
self._completed.append(
RemoteQueryResult(entry, error=f"cannot start remote query: {error}")
)
continue
self._active[entry.target] = _RemoteJob(entry, process, time.monotonic())
@staticmethod
def _stop_process(process: subprocess.Popen[str]) -> None:
if process.poll() is not None:
process.communicate()
return
process.terminate()
try:
process.communicate(timeout=0.25)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
+676
View File
@@ -0,0 +1,676 @@
from __future__ import annotations
from dataclasses import dataclass
import curses
import re
from typing import Iterable
from .remotes import (
RegistryError,
RemoteEntry,
RemoteQueryPool,
RemoteQueryResult,
RemoteRegistry,
)
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
class SelectorError(RuntimeError):
"""The interactive selector could not be displayed."""
@dataclass(frozen=True)
class CatalogWindow:
window_id: str
key: str
label: str
dead: bool
active: bool
kind: str | None = None
target: str | None = None
session: str | None = None
@dataclass(frozen=True)
class SelectorResult:
kind: str
window_id: str | None = None
dead: bool = False
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
@classmethod
def window(cls, window_id: str, *, dead: bool) -> SelectorResult:
return cls(kind="window", window_id=window_id, dead=dead)
@classmethod
def close(cls, window_id: str) -> SelectorResult:
return cls(kind="close", window_id=window_id)
@classmethod
def local(cls, session: str) -> SelectorResult:
return cls(kind="local", session=session)
@classmethod
def remote(
cls,
target: str,
session: str,
remote_keybindings: str,
) -> SelectorResult:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
)
@dataclass(frozen=True)
class PickerRow:
key: str
kind: str
main: str
detail: str = ""
selectable: bool = True
window_id: str | None = None
dead: bool = False
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
def build_main_rows(
windows: Iterable[CatalogWindow],
entries: Iterable[RemoteEntry],
results: dict[str, RemoteQueryResult],
loading_targets: set[str],
*,
local_sessions: Iterable[str] = (),
registry_error: str | None = None,
) -> list[PickerRow]:
window_list = list(windows)
entry_list = sorted(entries, key=lambda entry: entry.target)
registered = {entry.target for entry in entry_list}
rows: list[PickerRow] = []
standalone = [
window
for window in window_list
if window.kind == "local"
or window.target is None
or window.target not in registered
]
if standalone:
rows.append(PickerRow("heading:open", "heading", "已打开", selectable=False))
for window in standalone:
rows.append(_window_row(window))
open_local_sessions = {
window.session
for window in window_list
if window.kind == "local" and window.session is not None
}
available_local_sessions = sorted(
{
session
for session in local_sessions
if SESSION_NAME_RE.fullmatch(session) and session not in open_local_sessions
},
key=lambda name: (name != "default", name),
)
if available_local_sessions:
rows.append(PickerRow("heading:local", "heading", "本机", selectable=False))
rows.extend(
PickerRow(
f"local:{session}",
"local",
f"○ {session}",
"未在 Tab 中 · 单击打开",
session=session,
)
for session in available_local_sessions
)
active_window = next((window for window in window_list if window.active), None)
if active_window is not None:
can_close = len(window_list) > 1
rows.append(
PickerRow(
"close-active",
"close",
f"− 关闭当前 Tab:{active_window.label}",
"Herdr Session 保持运行" if can_close else "至少保留一个 Session Tab",
selectable=can_close,
window_id=active_window.window_id,
)
)
remote_windows = {
(window.target, window.session): window
for window in window_list
if window.kind == "remote"
and window.target is not None
and window.session is not None
}
for entry in entry_list:
rows.append(
PickerRow(
f"heading:{entry.target}",
"heading",
entry.target,
"远端",
selectable=False,
)
)
result = results.get(entry.target)
session_by_name = (
{
session.name: session
for session in result.sessions
if SESSION_NAME_RE.fullmatch(session.name)
}
if result is not None and result.error is None
else {}
)
for target, session_name in remote_windows:
if target == entry.target and session_name is not None:
session_by_name.setdefault(session_name, None)
for session_name in sorted(
session_by_name,
key=lambda name: (name != "default", name),
):
window = remote_windows.get((entry.target, session_name))
if window is not None:
rows.append(_window_row(window))
continue
session = session_by_name[session_name]
assert session is not None
rows.append(
PickerRow(
f"remote:{entry.target}:{session_name}",
"remote",
f"{'○' if session.running else '■'} {session_name}",
"远端运行 · 单击打开" if session.running else "已停止 · 单击启动",
target=entry.target,
session=session_name,
remote_keybindings=entry.remote_keybindings,
)
)
if entry.target in loading_targets:
rows.append(
PickerRow(
f"loading:{entry.target}",
"loading",
"… 正在查询 Sessions",
selectable=False,
)
)
elif result is not None and result.error is not None:
rows.append(
PickerRow(
f"retry:{entry.target}",
"retry",
"! 查询失败 · 单击重试",
result.error,
target=entry.target,
)
)
elif result is not None and not session_by_name:
rows.append(
PickerRow(
f"empty:{entry.target}",
"empty",
"没有已有 Session",
selectable=False,
)
)
rows.append(
PickerRow(
f"new:{entry.target}",
"new",
"+ 新建 Session…",
entry.target,
target=entry.target,
remote_keybindings=entry.remote_keybindings,
)
)
if registry_error is not None:
rows.append(
PickerRow(
"registry-error",
"error",
"! 远端配置不可用",
registry_error,
selectable=False,
)
)
else:
rows.extend(
[
PickerRow("add-remote", "add", "+ 添加远端…", "SSH target"),
PickerRow("manage-remotes", "manage", "⚙ 管理远端…"),
]
)
return rows
def _window_row(window: CatalogWindow) -> PickerRow:
symbol = "▶" if window.active else "×" if window.dead else "●"
detail = "当前" if window.active else "已断线 · 单击重连" if window.dead else "Hub 已打开"
return PickerRow(
f"window:{window.window_id}",
"window",
f"{symbol} {window.label}",
detail,
window_id=window.window_id,
dead=window.dead,
target=window.target,
session=window.session,
)
def filter_rows(rows: Iterable[PickerRow], query: str) -> list[PickerRow]:
if not query:
return list(rows)
needle = query.casefold()
return [
row
for row in rows
if needle in f"{row.main} {row.detail}".casefold()
]
class SessionSelector:
def __init__(
self,
windows: Iterable[CatalogWindow],
registry: RemoteRegistry,
ssh_bin: str,
local_sessions: Iterable[str] = (),
) -> None:
self.windows = list(windows)
self.local_sessions = list(local_sessions)
self.registry = registry
self.pool = RemoteQueryPool(ssh_bin)
self.entries: dict[str, RemoteEntry] = {}
self.results: dict[str, RemoteQueryResult] = {}
self.registry_error: str | None = None
self.message = ""
self.query = ""
self.selected_key: str | None = None
self.scroll = 0
self.view = "main"
self.input_value = ""
self.input_target: str | None = None
self._drawn_rows: dict[int, PickerRow] = {}
self._load_registry()
def _load_registry(self) -> None:
try:
entries = self.registry.load()
except RegistryError as error:
self.registry_error = str(error)
return
self.registry_error = None
self.entries = {entry.target: entry for entry in entries}
self.pool.enqueue_many(entries)
def run(self) -> SelectorResult | None:
try:
return curses.wrapper(self._curses_main)
except curses.error as error:
raise SelectorError(f"could not initialize the terminal selector: {error}") from error
finally:
self.pool.close()
def _curses_main(self, screen) -> SelectorResult | None: # type: ignore[no-untyped-def]
screen.keypad(True)
screen.timeout(100)
try:
curses.curs_set(0)
except curses.error:
pass
try:
curses.use_default_colors()
except curses.error:
pass
curses.mouseinterval(150)
curses.mousemask(curses.ALL_MOUSE_EVENTS)
while True:
for result in self.pool.poll():
self.results[result.entry.target] = result
rows = self._rows()
filtered = filter_rows(rows, self.query) if self.view == "main" else rows
self._normalize_selection(filtered)
self._draw(screen, filtered)
try:
key = screen.get_wch()
except curses.error:
continue
outcome = self._handle_key(key, filtered)
if isinstance(outcome, SelectorResult) or outcome is False:
return outcome if isinstance(outcome, SelectorResult) else None
def _rows(self) -> list[PickerRow]:
if self.view == "manage":
rows = [PickerRow("manage-heading", "heading", "管理远端", selectable=False)]
rows.extend(
PickerRow(
f"remove:{entry.target}",
"remove",
f"− {entry.target}",
"单击后确认移除",
target=entry.target,
)
for entry in sorted(self.entries.values(), key=lambda item: item.target)
)
if len(rows) == 1:
rows.append(PickerRow("no-remotes", "empty", "没有已登记远端", selectable=False))
rows.append(PickerRow("manage-back", "back", "← 返回 Sessions"))
return rows
if self.view == "confirm-remove":
target = self.input_target or ""
return [
PickerRow("confirm-heading", "heading", f"移除 {target}?", "不会关闭已有窗口或远端 server", selectable=False),
PickerRow("confirm-yes", "confirm-yes", "移除", target=target),
PickerRow("confirm-no", "back", "取消"),
]
if self.view in {"input-add", "input-new"}:
return []
return build_main_rows(
self.windows,
self.entries.values(),
self.results,
{target for target in self.entries if self.pool.is_loading(target)},
local_sessions=self.local_sessions,
registry_error=self.registry_error,
)
def _handle_key(self, key, rows: list[PickerRow]): # type: ignore[no-untyped-def]
if self.view in {"input-add", "input-new"}:
return self._handle_input_key(key)
if key == curses.KEY_MOUSE:
return self._handle_mouse(rows)
if key in {curses.KEY_UP}:
self._move(rows, -1)
return None
if key in {curses.KEY_DOWN}:
self._move(rows, 1)
return None
if key == curses.KEY_PPAGE:
self._move(rows, -8)
return None
if key == curses.KEY_NPAGE:
self._move(rows, 8)
return None
if key in {curses.KEY_ENTER, "\n", "\r"}:
return self._activate(self._selected_row(rows))
if key == "\x12" and self.view == "main":
self.results.clear()
self.pool.enqueue_many(self.entries.values())
self.message = "正在刷新所有远端"
return None
if key == "\x1b":
if self.view != "main":
self._show_main()
return None
if self.query:
self.query = ""
self.selected_key = None
return None
return False
if key in {curses.KEY_BACKSPACE, "\x7f", "\b"} and self.view == "main":
self.query = self.query[:-1]
self.selected_key = None
return None
if isinstance(key, str) and key.isprintable() and self.view == "main":
if len(self.query) < 128:
self.query += key
self.selected_key = None
return None
return None
def _handle_input_key(self, key): # type: ignore[no-untyped-def]
if key == "\x1b":
self._show_main()
return None
if key in {curses.KEY_ENTER, "\n", "\r"}:
value = self.input_value.strip()
if self.view == "input-add":
return self._finish_add(value)
return self._finish_new(value)
if key in {curses.KEY_BACKSPACE, "\x7f", "\b"}:
self.input_value = self.input_value[:-1]
return None
if isinstance(key, str) and key.isprintable() and len(self.input_value) < 512:
self.input_value += key
return None
def _handle_mouse(self, rows: list[PickerRow]):
try:
_, _, y, _, state = curses.getmouse()
except curses.error:
return None
if state & getattr(curses, "BUTTON4_PRESSED", 0):
self._move(rows, -3)
return None
if state & getattr(curses, "BUTTON5_PRESSED", 0):
self._move(rows, 3)
return None
clicked = (
getattr(curses, "BUTTON1_CLICKED", 0)
| getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0)
)
if state & clicked:
row = self._drawn_rows.get(y)
if row is not None and row.selectable:
self.selected_key = row.key
return self._activate(row)
return None
def _activate(self, row: PickerRow | None):
if row is None:
return None
if row.kind == "window":
assert row.window_id is not None
return SelectorResult.window(row.window_id, dead=row.dead)
if row.kind == "close":
assert row.window_id is not None
return SelectorResult.close(row.window_id)
if row.kind == "local":
assert row.session is not None
return SelectorResult.local(row.session)
if row.kind == "remote":
assert row.target is not None and row.session is not None
return SelectorResult.remote(
row.target,
row.session,
row.remote_keybindings,
)
if row.kind == "retry":
assert row.target is not None
self.results.pop(row.target, None)
self.pool.enqueue(self.entries[row.target])
self.message = f"正在重试 {row.target}"
elif row.kind == "new":
self.view = "input-new"
self.input_target = row.target
self.input_value = ""
self.message = ""
elif row.kind == "add":
self.view = "input-add"
self.input_target = None
self.input_value = ""
self.message = ""
elif row.kind == "manage":
self.view = "manage"
self.query = ""
self.selected_key = None
elif row.kind == "remove":
self.view = "confirm-remove"
self.input_target = row.target
self.selected_key = "confirm-no"
elif row.kind == "confirm-yes":
self._finish_remove(row.target or "")
elif row.kind == "back":
self._show_main()
return None
def _finish_add(self, target: str):
try:
entry = RemoteEntry(target)
self.registry.upsert(entry)
except (ValueError, RegistryError) as error:
self.message = str(error)
return None
self.entries[target] = entry
self.results.pop(target, None)
self.pool.enqueue(entry)
self._show_main()
self.message = f"已添加 {target},正在查询"
return None
def _finish_new(self, session: str):
if not SESSION_NAME_RE.fullmatch(session):
self.message = "Session 名称需为 1-64 位字母、数字、. _ -"
return None
target = self.input_target
assert target is not None
entry = self.entries[target]
return SelectorResult.remote(target, session, entry.remote_keybindings)
def _finish_remove(self, target: str) -> None:
try:
self.registry.remove(target)
except RegistryError as error:
self.message = str(error)
self._show_main()
return
self.pool.cancel(target)
self.entries.pop(target, None)
self.results.pop(target, None)
self._show_main()
self.message = f"已移除 {target};已有 window 保持不变"
def _show_main(self) -> None:
self.view = "main"
self.input_target = None
self.input_value = ""
self.selected_key = None
self.scroll = 0
def _normalize_selection(self, rows: list[PickerRow]) -> None:
selectable = [row for row in rows if row.selectable]
if not selectable:
self.selected_key = None
self.scroll = 0
return
if self.selected_key not in {row.key for row in selectable}:
self.selected_key = selectable[0].key
def _selected_row(self, rows: list[PickerRow]) -> PickerRow | None:
return next((row for row in rows if row.key == self.selected_key), None)
def _move(self, rows: list[PickerRow], delta: int) -> None:
selectable = [row for row in rows if row.selectable]
if not selectable:
return
current = next(
(index for index, row in enumerate(selectable) if row.key == self.selected_key),
0,
)
next_index = max(0, min(len(selectable) - 1, current + delta))
self.selected_key = selectable[next_index].key
def _draw(self, screen, rows: list[PickerRow]) -> None: # type: ignore[no-untyped-def]
screen.erase()
height, width = screen.getmaxyx()
self._drawn_rows = {}
if height < 8 or width < 28:
self._safe_add(screen, 0, 0, "终端太小,请旋转屏幕", curses.A_BOLD)
screen.refresh()
return
if self.view in {"input-add", "input-new"}:
self._draw_input(screen, height, width)
return
try:
curses.curs_set(0)
except curses.error:
pass
title = "Sessions" if self.view == "main" else "管理远端"
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
if self.view == "main":
self._safe_add(screen, 2, 2, f"搜索: {self.query}", curses.A_DIM)
if self.message:
self._safe_add(screen, 3, 2, self.message, curses.A_DIM)
list_top = 4
list_height = max(1, height - list_top - 2)
selected_index = next(
(index for index, row in enumerate(rows) if row.key == self.selected_key),
0,
)
if selected_index < self.scroll:
self.scroll = selected_index
elif selected_index >= self.scroll + list_height:
self.scroll = selected_index - list_height + 1
self.scroll = max(0, min(self.scroll, max(0, len(rows) - list_height)))
for offset, row in enumerate(rows[self.scroll : self.scroll + list_height]):
y = list_top + offset
self._drawn_rows[y] = row
selected = row.key == self.selected_key
attribute = curses.A_REVERSE if selected else curses.A_NORMAL
if row.kind == "heading":
attribute |= curses.A_BOLD
elif not row.selectable:
attribute |= curses.A_DIM
detail_room = max(0, width // 2 - 2)
main_room = max(8, width - detail_room - 6)
main = row.main[:main_room]
detail = row.detail[:detail_room]
line = f" {main:<{main_room}} {detail}"
self._safe_add(screen, y, 0, line, attribute)
footer = "单击/Enter 打开 · 输入搜索 · Ctrl+R 刷新 · Esc 关闭"
if self.view != "main":
footer = "单击/Enter 选择 · Esc 返回"
self._safe_add(screen, height - 1, 1, footer, curses.A_DIM)
screen.refresh()
def _draw_input(self, screen, height: int, width: int) -> None: # type: ignore[no-untyped-def]
is_add = self.view == "input-add"
title = "添加远端" if is_add else f"在 {self.input_target} 新建 Session"
prompt = "SSH target" if is_add else "Session 名称"
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
self._safe_add(screen, 3, 2, f"{prompt}: {self.input_value}")
if self.message:
self._safe_add(screen, 5, 2, self.message, curses.A_DIM)
self._safe_add(screen, height - 1, 1, "Enter 确认 · Esc 返回", curses.A_DIM)
try:
curses.curs_set(1)
screen.move(3, min(width - 2, len(prompt) + len(self.input_value) + 4))
except curses.error:
pass
screen.refresh()
@staticmethod
def _safe_add(screen, y: int, x: int, text: str, attribute: int = curses.A_NORMAL) -> None: # type: ignore[no-untyped-def]
height, width = screen.getmaxyx()
if y < 0 or y >= height or x >= width:
return
try:
screen.addnstr(y, x, text, max(0, width - x - 1), attribute)
except curses.error:
pass
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repository_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
install_dir=${XDG_BIN_HOME:-"$HOME/.local/bin"}
mkdir -p "$install_dir"
ln -sfn "$repository_root/bin/herdr-hub" "$install_dir/herdr-hub"
printf 'installed %s\n' "$install_dir/herdr-hub"
case ":${PATH:-}:" in
*:"$install_dir":*) ;;
*) printf 'note: add %s to PATH\n' "$install_dir" ;;
esac
+1
View File
@@ -0,0 +1 @@
"""Test suite for herdr-hub."""
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import uuid
from herdr_hub.cli import TmuxHub
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
class CliIntegrationTests(unittest.TestCase):
def test_remote_registry_cli_round_trip(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
launcher = repository_root / "bin" / "herdr-hub"
with tempfile.TemporaryDirectory() as directory:
environment = {
**os.environ,
"HERDR_HUB_REMOTES_FILE": str(Path(directory) / "remotes.json"),
}
add = subprocess.run(
[
str(launcher),
"remote",
"add",
"orangepi",
"--remote-keybindings",
"server",
],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(add.returncode, 0, add.stderr)
listing = subprocess.run(
[str(launcher), "remote", "list"],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(listing.stdout.strip(), "orangepi\tserver")
remove = subprocess.run(
[str(launcher), "remote", "remove", "orangepi"],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(remove.returncode, 0, remove.stderr)
self.assertIn("removed orangepi", remove.stdout)
def test_repository_launcher_refreshes_and_stops_isolated_hub(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
launcher = repository_root / "bin" / "herdr-hub"
socket_name = f"herdr-hub-cli-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
socket_path = Path(
os.environ.get("TMUX_TMPDIR", f"/tmp/tmux-{os.getuid()}")
) / socket_name
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
proc_root = root / "proc"
proc_root.mkdir()
fake_herdr = root / "herdr"
fake_herdr.write_text(
"#!/bin/sh\n"
"if [ \"${1:-}\" = \"session\" ]; then\n"
" printf '%s\\n' '{\"sessions\":[{\"name\":\"default\",\"running\":true}]}'\n"
" exit 0\n"
"fi\n"
"exec sleep 30\n",
encoding="utf-8",
)
fake_herdr.chmod(0o755)
environment = {
**os.environ,
"HERDR_HUB_HERDR_BIN": str(fake_herdr),
"HERDR_HUB_TMUX_BIN": shutil.which("tmux") or "tmux",
"HERDR_HUB_SOCKET": socket_name,
"HERDR_HUB_SESSION": "hub",
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
"HERDR_HUB_PROC_ROOT": str(proc_root),
}
refresh = subprocess.run(
[str(launcher), "refresh"],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(refresh.returncode, 0, refresh.stderr)
self.assertIn("added 1 session", refresh.stdout)
hub = TmuxHub(
tmux_bin=shutil.which("tmux") or "tmux",
socket_name=socket_name,
session_name="hub",
config_file="/dev/null",
script_path=str(launcher),
)
def cleanup_hub() -> None:
if hub.exists():
hub.stop()
socket_path.unlink(missing_ok=True)
self.addCleanup(cleanup_hub)
self.assertEqual(
[window.key for window in hub.managed_windows()],
["local:default"],
)
stop = subprocess.run(
[str(launcher), "stop"],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(stop.returncode, 0, stop.stderr)
self.assertIn("Herdr servers were left running", stop.stdout)
self.assertFalse(hub.exists())
if __name__ == "__main__":
unittest.main()
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from herdr_hub.cli import (
DEFAULT_SESSION_NAME,
Endpoint,
decode_cmdline,
dedupe_endpoints,
deserialize_endpoint,
discover_local_sessions,
discover_remote_processes,
parse_remote_argv,
serialize_endpoint,
)
class EndpointTests(unittest.TestCase):
def test_local_and_remote_commands(self) -> None:
self.assertEqual(Endpoint.local().command("herdr"), ["herdr"])
self.assertEqual(
Endpoint.local("work").command("/bin/herdr"),
["/bin/herdr", "--session", "work"],
)
self.assertEqual(
Endpoint.remote("orangepi", "work", "server").command("herdr"),
[
"herdr",
"--remote",
"orangepi",
"--remote-keybindings",
"server",
"--session",
"work",
],
)
def test_identity_ignores_remote_keybinding_mode(self) -> None:
local = Endpoint.remote("orangepi", remote_keybindings="local")
server = Endpoint.remote("orangepi", remote_keybindings="server")
self.assertEqual(local.key, server.key)
self.assertEqual(dedupe_endpoints([local, server]), [local])
def test_short_labels_put_the_host_first_and_omit_default(self) -> None:
self.assertEqual(Endpoint.local().short_label, "local")
self.assertEqual(Endpoint.local("work").short_label, "local/work")
self.assertEqual(Endpoint.remote("orangepi").short_label, "orangepi")
self.assertEqual(Endpoint.remote("orangepi", "work").short_label, "orangepi/work")
def test_default_local_sorts_first(self) -> None:
endpoints = dedupe_endpoints(
[
Endpoint.remote("z"),
Endpoint.local("work"),
Endpoint.local(),
Endpoint.remote("a"),
]
)
self.assertEqual(
[endpoint.key for endpoint in endpoints],
["local:default", "local:work", "remote:a:default", "remote:z:default"],
)
def test_endpoint_metadata_and_legacy_command_round_trip(self) -> None:
endpoint = Endpoint.remote("ssh://me@host:2222", "work", "server")
self.assertEqual(deserialize_endpoint(serialize_endpoint(endpoint)), endpoint)
self.assertEqual(
deserialize_endpoint(
"",
"exec /usr/local/bin/herdr --remote orangepi --session work",
),
Endpoint.remote("orangepi", "work"),
)
class RemoteArgvTests(unittest.TestCase):
def test_separate_arguments(self) -> None:
endpoint = parse_remote_argv(
[
"/home/me/.local/bin/herdr",
"--remote",
"orangepi",
"--session",
"work",
"--remote-keybindings",
"server",
]
)
self.assertEqual(endpoint, Endpoint.remote("orangepi", "work", "server"))
def test_equal_arguments_and_arbitrary_order(self) -> None:
endpoint = parse_remote_argv(
["herdr", "--session=work", "--remote=ssh://me@host:2222"]
)
self.assertEqual(endpoint, Endpoint.remote("ssh://me@host:2222", "work"))
def test_default_session(self) -> None:
endpoint = parse_remote_argv(["herdr", "--remote", "orangepi"])
self.assertIsNotNone(endpoint)
assert endpoint is not None
self.assertEqual(endpoint.session, DEFAULT_SESSION_NAME)
def test_non_herdr_and_malformed_commands_are_ignored(self) -> None:
self.assertIsNone(parse_remote_argv(["bash", "-c", "herdr --remote host"]))
self.assertIsNone(parse_remote_argv(["herdr", "--remote"]))
self.assertIsNone(parse_remote_argv(["herdr", "--remote", "-bad"]))
def test_cmdline_is_nul_delimited(self) -> None:
self.assertEqual(
decode_cmdline(b"/bin/herdr\0--remote\0host name\0"),
["/bin/herdr", "--remote", "host name"],
)
class DiscoveryTests(unittest.TestCase):
def test_local_discovery_returns_only_running_sessions(self) -> None:
payload = {
"sessions": [
{"name": "default", "running": True},
{"name": "work", "running": True},
{"name": "old", "running": False},
]
}
def fake_run(*args, **kwargs): # type: ignore[no-untyped-def]
return subprocess.CompletedProcess(args[0], 0, json.dumps(payload), "")
report = discover_local_sessions("herdr", run=fake_run)
self.assertEqual(
[endpoint.key for endpoint in report.endpoints],
["local:default", "local:work"],
)
self.assertEqual(report.warnings, [])
def test_invalid_local_json_is_a_warning(self) -> None:
def fake_run(*args, **kwargs): # type: ignore[no-untyped-def]
return subprocess.CompletedProcess(args[0], 0, "not json", "")
report = discover_local_sessions("herdr", run=fake_run)
self.assertEqual(report.endpoints, [])
self.assertEqual(len(report.warnings), 1)
def test_proc_discovery_reads_exact_argv_and_deduplicates(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
commands = {
"101": ["/usr/bin/herdr", "--remote", "orangepi"],
"102": ["herdr", "--remote=orangepi"],
"103": ["herdr", "--remote", "prod", "--session", "work"],
"104": ["bash", "-c", "herdr --remote ignored"],
}
for process_id, argv in commands.items():
process = root / process_id
process.mkdir()
(process / "cmdline").write_bytes(
b"\0".join(os.fsencode(argument) for argument in argv) + b"\0"
)
report = discover_remote_processes(root, user_id=os.getuid())
self.assertEqual(
[endpoint.key for endpoint in report.endpoints],
["remote:orangepi:default", "remote:prod:work"],
)
if __name__ == "__main__":
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from pathlib import Path
import stat
import tempfile
import time
import unittest
from herdr_hub.remotes import (
RegistryError,
RemoteEntry,
RemoteQueryPool,
RemoteRegistry,
RemoteTargetError,
parse_remote_session_result,
remote_session_command,
ssh_destination_arguments,
)
class RemoteRegistryTests(unittest.TestCase):
def test_round_trip_update_remove_and_permissions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config" / "remotes.json"
registry = RemoteRegistry(path)
self.assertEqual(registry.load(), [])
self.assertTrue(registry.upsert(RemoteEntry("orangepi")))
self.assertFalse(registry.upsert(RemoteEntry("orangepi")))
self.assertTrue(registry.upsert(RemoteEntry("prod", "server")))
self.assertEqual(
registry.load(),
[RemoteEntry("orangepi"), RemoteEntry("prod", "server")],
)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertTrue(registry.remove("orangepi"))
self.assertFalse(registry.remove("missing"))
self.assertEqual(registry.load(), [RemoteEntry("prod", "server")])
def test_corrupt_registry_is_not_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "remotes.json"
path.write_text("not json", encoding="utf-8")
registry = RemoteRegistry(path)
with self.assertRaises(RegistryError):
registry.upsert(RemoteEntry("orangepi"))
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
class RemoteSshTests(unittest.TestCase):
def test_alias_and_url_targets_become_safe_ssh_arguments(self) -> None:
self.assertEqual(ssh_destination_arguments("orangepi"), ["orangepi"])
self.assertEqual(
ssh_destination_arguments("ssh://me@example.com:2222"),
["-p", "2222", "-l", "me", "example.com"],
)
command = remote_session_command("/usr/bin/ssh", RemoteEntry("host name"))
self.assertEqual(command[0], "/usr/bin/ssh")
self.assertIn("host name", command)
self.assertNotIn("sh", command[:1])
with self.assertRaises(RemoteTargetError):
ssh_destination_arguments("ssh://me:secret@example.com")
with self.assertRaises(RemoteTargetError):
ssh_destination_arguments("ssh://example.com/a/path")
def test_remote_session_json_keeps_running_and_stopped(self) -> None:
result = parse_remote_session_result(
RemoteEntry("orangepi"),
0,
'{"sessions":['
'{"name":"old","running":false},'
'{"name":"default","running":true}]}',
"",
)
self.assertIsNone(result.error)
self.assertEqual(
[(session.name, session.running) for session in result.sessions],
[("default", True), ("old", False)],
)
def test_query_pool_reports_results_and_spawn_failures(self) -> None:
with tempfile.TemporaryDirectory() as directory:
fake_ssh = Path(directory) / "ssh"
fake_ssh.write_text(
"#!/bin/sh\n"
"printf '%s\\n' '{\"sessions\":[{\"name\":\"work\",\"running\":true}]}'\n",
encoding="utf-8",
)
fake_ssh.chmod(0o755)
pool = RemoteQueryPool(str(fake_ssh), query_timeout=1)
self.addCleanup(pool.close)
pool.enqueue(RemoteEntry("orangepi"))
results = []
deadline = time.monotonic() + 2
while not results and time.monotonic() < deadline:
results = pool.poll()
time.sleep(0.01)
self.assertEqual(results[0].sessions[0].name, "work")
missing = RemoteQueryPool(str(Path(directory) / "missing"))
self.addCleanup(missing.close)
missing.enqueue(RemoteEntry("orangepi"))
failure = missing.poll()
self.assertEqual(len(failure), 1)
self.assertIn("cannot start", failure[0].error or "")
def test_query_pool_times_out_without_blocking_other_results(self) -> None:
with tempfile.TemporaryDirectory() as directory:
slow_ssh = Path(directory) / "ssh"
slow_ssh.write_text("#!/bin/sh\nexec sleep 5\n", encoding="utf-8")
slow_ssh.chmod(0o755)
pool = RemoteQueryPool(str(slow_ssh), query_timeout=0.05)
self.addCleanup(pool.close)
pool.enqueue(RemoteEntry("slow"))
time.sleep(0.08)
results = pool.poll()
self.assertEqual(len(results), 1)
self.assertIn("timed out", results[0].error or "")
if __name__ == "__main__":
unittest.main()
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import os
from pathlib import Path
import pty
import select
import signal
import tempfile
import time
import unittest
from herdr_hub.remotes import RemoteEntry, RemoteQueryResult, RemoteRegistry, RemoteSession
from herdr_hub.selector import (
CatalogWindow,
PickerRow,
SessionSelector,
SelectorResult,
build_main_rows,
filter_rows,
)
class SelectorCatalogTests(unittest.TestCase):
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
windows = [
CatalogWindow(
"@1",
"local:default",
"L default",
False,
True,
kind="local",
session="default",
),
CatalogWindow(
"@2",
"remote:orangepi:work",
"R orangepi/work",
True,
False,
kind="remote",
target="orangepi",
session="work",
),
]
result = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(
RemoteSession("default", True),
RemoteSession("old", False),
RemoteSession("work", True),
),
)
rows = build_main_rows(
windows,
[RemoteEntry("orangepi")],
{"orangepi": result},
set(),
local_sessions=["default", "draft"],
)
by_key = {row.key: row for row in rows}
self.assertEqual(by_key["window:@1"].main, "▶ L default")
self.assertEqual(by_key["window:@2"].main, "× R orangepi/work")
self.assertIn("remote:orangepi:default", by_key)
self.assertIn("remote:orangepi:old", by_key)
self.assertIn("new:orangepi", by_key)
self.assertEqual(by_key["local:draft"].main, "○ draft")
self.assertEqual(by_key["close-active"].window_id, "@1")
def test_loading_error_and_filter_rows(self) -> None:
entry = RemoteEntry("prod")
loading = build_main_rows([], [entry], {}, {"prod"})
self.assertTrue(any(row.kind == "loading" for row in loading))
failed = build_main_rows(
[],
[entry],
{"prod": RemoteQueryResult(entry, error="offline")},
set(),
)
self.assertTrue(any(row.kind == "retry" for row in failed))
matches = filter_rows(failed, "offline")
self.assertEqual([row.kind for row in matches], ["retry"])
class SelectorActionTests(unittest.TestCase):
def test_dead_window_reconnect_action_and_new_session_action(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi", "server"))
selector = SessionSelector(
[CatalogWindow("@2", "key", "remote", True, False)],
registry,
str(Path(directory) / "missing-ssh"),
)
self.addCleanup(selector.pool.close)
window_result = selector._activate( # type: ignore[attr-defined]
next(row for row in selector._rows() if row.kind == "window") # type: ignore[attr-defined]
)
self.assertEqual(window_result, SelectorResult.window("@2", dead=True))
close_result = selector._activate( # type: ignore[attr-defined]
PickerRow("close", "close", "close", window_id="@2")
)
self.assertEqual(close_result, SelectorResult.close("@2"))
local_result = selector._activate( # type: ignore[attr-defined]
PickerRow("local", "local", "local", session="draft")
)
self.assertEqual(local_result, SelectorResult.local("draft"))
selector.view = "input-new"
selector.input_target = "orangepi"
new_result = selector._finish_new("work") # type: ignore[attr-defined]
self.assertEqual(
new_result,
SelectorResult.remote("orangepi", "work", "server"),
)
self.assertIsNone(selector._finish_new("bad name")) # type: ignore[attr-defined]
@unittest.skipUnless(hasattr(pty, "fork"), "a Unix PTY is required")
def test_curses_selector_handles_single_click_and_esc(self) -> None:
with tempfile.TemporaryDirectory() as directory:
pid, descriptor = pty.fork()
if pid == 0:
os.environ["TERM"] = "xterm-256color"
selector = SessionSelector(
[],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
)
try:
result = selector.run()
os._exit(0 if result is None else 2)
except BaseException:
os._exit(3)
status: int | None = None
try:
deadline = time.monotonic() + 3
output = b""
while time.monotonic() < deadline and b"Sessions" not in output:
readable, _, _ = select.select([descriptor], [], [], 0.1)
if readable:
output += os.read(descriptor, 4096)
self.assertIn(b"Sessions", output)
# Empty registries render "+ 添加远端" as the first selectable
# row at terminal row 5. A single SGR mouse click must activate it.
os.write(descriptor, b"\x1b[<0;5;5M\x1b[<0;5;5m")
opened_input = b""
input_deadline = time.monotonic() + 2
while time.monotonic() < input_deadline and b"SSH target" not in opened_input:
readable, _, _ = select.select([descriptor], [], [], 0.1)
if readable:
opened_input += os.read(descriptor, 4096)
self.assertIn(b"SSH target", opened_input)
os.write(descriptor, b"\x1b\x1b")
while time.monotonic() < deadline:
completed, wait_status = os.waitpid(pid, os.WNOHANG)
if completed == pid:
status = wait_status
break
time.sleep(0.05)
self.assertIsNotNone(status, "selector did not exit after Esc")
assert status is not None
self.assertTrue(os.WIFEXITED(status))
self.assertEqual(os.WEXITSTATUS(status), 0)
finally:
try:
os.close(descriptor)
except OSError:
pass
if status is None:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
os.waitpid(pid, 0)
if __name__ == "__main__":
unittest.main()
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import os
from pathlib import Path
import shutil
import tempfile
import time
import unittest
import uuid
from herdr_hub.cli import (
BOOTSTRAP_WINDOW_NAME,
Endpoint,
HubError,
TmuxHub,
WINDOW_SHORT_LABEL_OPTION,
)
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
class TmuxIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
root = Path(self.temporary_directory.name)
self.fake_herdr = root / "herdr"
self.fake_herdr.write_text(
"#!/bin/sh\n"
"if [ \"${1:-}\" = \"--session\" ] && [ \"${2:-}\" = \"dead\" ]; then\n"
" exit 0\n"
"fi\n"
"exec sleep 30\n",
encoding="utf-8",
)
self.fake_herdr.chmod(0o755)
self.socket_name = f"herdr-hub-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
self.socket_path = Path(
os.environ.get("TMUX_TMPDIR", f"/tmp/tmux-{os.getuid()}")
) / self.socket_name
self.hub = TmuxHub(
tmux_bin=shutil.which("tmux") or "tmux",
socket_name=self.socket_name,
session_name="hub",
config_file="/dev/null",
script_path="/dev/null",
)
self.addCleanup(self._stop_hub)
def _stop_hub(self) -> None:
try:
self.hub.stop()
except Exception:
pass
self.socket_path.unlink(missing_ok=True)
def test_configures_isolated_server_and_adds_without_duplicates(self) -> None:
self.assertTrue(self.hub.ensure_server())
endpoints = [Endpoint.local(), Endpoint.remote("orangepi")]
added = self.hub.add_missing(endpoints, str(self.fake_herdr))
self.assertEqual(len(added), 2)
self.assertEqual(self.hub.add_missing(endpoints, str(self.fake_herdr)), [])
windows = self.hub.managed_windows()
self.assertEqual(
{window.key for window in windows},
{"local:default", "remote:orangepi:default"},
)
remote_window = next(
window for window in windows if window.key == "remote:orangepi:default"
)
self.assertEqual(remote_window.endpoint, Endpoint.remote("orangepi"))
all_names = self.hub.run(
["list-windows", "-t", "hub", "-F", "#{window_name}"]
).stdout.splitlines()
self.assertNotIn(BOOTSTRAP_WINDOW_NAME, all_names)
prefix = self.hub.run(["show-options", "-gv", "prefix"]).stdout.strip()
status = self.hub.run(["show-options", "-gv", "status"]).stdout.strip()
position = self.hub.run(
["show-options", "-gv", "status-position"]
).stdout.strip()
mouse = self.hub.run(["show-options", "-gv", "mouse"]).stdout.strip()
clipboard = self.hub.run(
["show-options", "-sv", "set-clipboard"]
).stdout.strip()
status_right = self.hub.run(
["show-options", "-gv", "status-right"]
).stdout.strip()
dead_format = self.hub.run(
["show-options", "-wgv", "remain-on-exit-format"]
).stdout.strip()
inactive_window_format = self.hub.run(
["show-options", "-wgv", "window-status-format"]
).stdout.strip()
current_window_format = self.hub.run(
["show-options", "-wgv", "window-status-current-format"]
).stdout.strip()
status_left_binding = self.hub.run(
["list-keys", "-T", "root", "MouseDown1StatusLeft"]
).stdout.strip()
close_binding = self.hub.run(
["list-keys", "-T", "prefix", "&"]
).stdout.strip()
self.assertEqual(prefix, "C-a")
self.assertEqual(status, "on")
self.assertEqual(position, "top")
self.assertEqual(mouse, "on")
self.assertEqual(clipboard, "on")
self.assertIn("连接已断开", status_right)
self.assertIn("C-a R", status_right)
self.assertIn("连接已断开", dead_format)
self.assertIn("Ctrl+A R", dead_format)
self.assertIn("client_width", inactive_window_format)
self.assertIn(WINDOW_SHORT_LABEL_OPTION, inactive_window_format)
self.assertNotIn("#I", inactive_window_format)
self.assertIn("client_width", current_window_format)
self.assertIn(WINDOW_SHORT_LABEL_OPTION, current_window_format)
self.assertIn("display-popup", status_left_binding)
self.assertIn("confirm-before", close_binding)
self.assertIn(" close --window ", close_binding)
local_short_label = self.hub.run(
[
"show-options",
"-wv",
"-t",
next(window.window_id for window in windows if window.key == "local:default"),
WINDOW_SHORT_LABEL_OPTION,
]
).stdout.strip()
remote_short_label = self.hub.run(
[
"show-options",
"-wv",
"-t",
remote_window.window_id,
WINDOW_SHORT_LABEL_OPTION,
]
).stdout.strip()
rendered_local_short_label = self.hub.run(
[
"display-message",
"-p",
"-t",
next(window.window_id for window in windows if window.key == "local:default"),
"#{=12:@herdr_hub_short_label}",
]
).stdout.strip()
self.assertEqual(local_short_label, "local")
self.assertEqual(remote_short_label, "orangepi")
self.assertEqual(rendered_local_short_label, "local")
def test_dismissed_window_stays_hidden_until_explicitly_reopened(self) -> None:
self.hub.ensure_server()
endpoints = [Endpoint.local(), Endpoint.local("work")]
self.hub.add_missing(endpoints, str(self.fake_herdr))
default_window = self.hub.window_for_key("local:default")
assert default_window is not None
self.hub.dismiss_window(default_window.window_id)
self.assertEqual(self.hub.dismissed_keys(), {"local:default"})
self.assertIsNone(self.hub.window_for_key("local:default"))
self.assertEqual(self.hub.add_missing(endpoints, str(self.fake_herdr)), [])
reopened = self.hub.activate_endpoint(Endpoint.local(), str(self.fake_herdr))
self.assertEqual(reopened.key, "local:default")
self.assertEqual(self.hub.dismissed_keys(), set())
work_window = self.hub.window_for_key("local:work")
assert work_window is not None
self.hub.dismiss_window(work_window.window_id)
with self.assertRaises(HubError):
self.hub.dismiss_window(reopened.window_id)
def test_dead_window_is_retained_and_can_be_respawned(self) -> None:
self.hub.ensure_server()
window = self.hub.create_window(Endpoint.local("dead"), str(self.fake_herdr))
self.hub.remove_bootstrap()
deadline = time.monotonic() + 3
dead = False
while time.monotonic() < deadline:
current = self.hub.window_for_key("local:dead")
dead = current.dead if current is not None else False
if dead:
break
time.sleep(0.05)
self.assertTrue(dead)
rendered_status = self.hub.run(
[
"display-message",
"-p",
"-t",
window.window_id,
"#{?pane_dead,× #{window_name} [断开],● #{window_name}}",
]
).stdout.strip()
self.assertEqual(rendered_status, f"× {window.name} [断开]")
self.hub.reconnect(window.window_id)
self.assertIsNotNone(self.hub.window_for_key("local:dead"))
if __name__ == "__main__":
unittest.main()