Improve remote session management and Windows support

This commit is contained in:
2026-09-03 12:58:24 +00:00
parent 6c2c9672e1
commit 02d83a04bd
12 changed files with 2342 additions and 146 deletions
+48 -11
View File
@@ -12,11 +12,13 @@ sidebar 插入 UI,也不能让 thin client 从一个 server 原地改连另一
- 自动读取 `herdr session list --json`,导入本机正在运行的 sessions。
- 记住使用过的远端,并通过 SSH 实时读取运行中及已停止的 Herdr sessions。
- 远端查询兼容 Windows OpenSSH 与 Linux/macOS shell;Windows target 自动改用并持久记住 SSH TTY 模式。
- 在 Linux `/proc/*/cmdline` 中查找仍在运行的 `herdr --remote …` clients,并自动记住目标。
- 首次接管已有 remote client 时安全地再 attach,不结束原进程。
- 一个 endpoint 对应一个 tmux window,隐藏 window 中的连接继续在线。
- 顶部 Sessions 条在窄屏下也为每个 Tab 显示 Session 优先的短名称,并支持鼠标点击、数字切换和无需 fzf 的动态 popup 选择。
- 顶部 Sessions 条在窄屏下也为每个 Tab 显示主机优先的短名称,并支持鼠标点击、数字切换和无需 fzf 的动态 popup 选择。
- 可隐藏不再需要的 Tab,而不停止底层本地或远端 Herdr Session。
- 可在 Sessions 管理弹窗中为 Tab 设置持久化显示名称,或恢复默认名称。
- dead window 留在顶栏,可原地重连。
- 不修改 `~/.tmux.conf`,不保存 SSH 凭据或远端 Session 缓存。
@@ -70,6 +72,7 @@ server。remote target 在 client 退出后没有本地记录,因此直接使
herdr-hub open --remote orangepi
herdr-hub open --remote orangepi --session work
herdr-hub open --remote ssh://me@server:2222 --remote-keybindings server
herdr-hub open --remote windows-host --transport ssh
```
打开本地 named session:
@@ -95,7 +98,9 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
| 按编号切换 | `Ctrl+A 0..9` |
| 扫描新增 sessions | `Ctrl+A r` 或 `herdr-hub refresh` |
| 强制重连当前 window | `Ctrl+A R` |
| 关闭/隐藏当前 Tab | `Ctrl+A &`,确认 `y`;也可在 Sessions 弹窗中选择 |
| 关闭/隐藏当前 Tab | `Ctrl+A q`,确认 `y`;也可在 Sessions 弹窗中选择 |
| 关闭并停止当前远端 Session | Sessions 弹窗 → `关闭并停止当前 Session`;最后一个 Tab 会保留为断线状态 |
| 修改 Tab 显示名称 | Sessions 弹窗 → `管理 Session 显示名称…` |
| detach Hub | `Ctrl+A d` |
| 重新进入 Hub | `herdr-hub` |
| 停止 Hub clients | `herdr-hub stop` |
@@ -112,24 +117,53 @@ window,同时在后台并行查询所有已登记远端;每台机器的结
| `▶` 当前 / `●` 已打开 | 跳转到已有 window |
| `×` 已断线 | 自动重连并跳转 |
| `○` 远端运行 | 新建 attach window 并跳转 |
| `■` 远端已停止 | 启动该 Session、attach 并跳转 |
| `■` 远端已停止 | Enter/单击启动;按 `d` 可删除历史 Session |
| `↻` Windows 旧窗口 | 原地切换为 SSH TTY 模式并重连 |
弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr
Session 和工作现场继续运行。每个远端下方都有“新建 Session”入口。弹窗底部还可添加或管理远端;移除远端只删除
Session 和工作现场继续运行。当前 Tab 是远端 Session 时还会显示“关闭并停止当前
Session”;二次确认后会在后台停止远端 Herdr Session、中断其中正在运行的任务并关闭
对应 Tab,但保留为 `■ 已停止`,以后仍可从弹窗重新启动。若它是 Hub 中最后一个 Tab,
停止后会保留该 Tab 为断线状态,避免销毁承载弹窗的最后一个 window。
“管理 Session 显示名称”可给已打开的本地或远端 Tab
设置最多 48 字符的名称;输入留空可恢复默认。每个远端下方都有“新建 Session”入口。
弹窗底部还可添加普通远端、明确添加“Windows/SSH 远端”,或管理已登记远端;移除远端只删除
记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行,
键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。
选中标记为 `■ 已停止` 且没有对应 Hub Tab 的远端 Session 后,按小写 `d` 会进入
二次确认;可点击“确认删除”、按 Enter,或再按一次 `d`。确认后在后台通过 SSH 执行
`herdr session delete`,永久删除该 Session 的工作现场
和历史数据,并留在弹窗中继续管理。运行中的 Session 和仍有 Tab 的 Session 不允许删除。
Herdr 0.8.2 不支持 Windows 作为原生 `herdr --remote` 的目标主机。Hub 查询到 Windows
Session 后会标记为“Windows · SSH 模式”,并自动改为 `ssh -tt target herdr`;命名
Session 会在远端运行 `herdr --session NAME`。这种模式仍支持 Tab 切换、detach、断线
重连和远端工作现场持久化,但使用远端 Herdr 的 keybindings,也没有原生 thin client
提供的本地图片剪贴板桥接。空 Windows 主机或平台检测失败时,可在弹窗选择
“添加 Windows/SSH 远端”,也可手动执行 `herdr-hub open --remote TARGET --transport ssh`。
也可通过 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 add windows-host --transport ssh
herdr-hub remote remove orangepi
```
注册表默认位于 `~/.config/herdr-hub/remotes.json`(遵循 `$XDG_CONFIG_HOME`),权限为
`0600`。它只保存 SSH target 和 remote keybinding 模式。
`0600`。它只保存 SSH target、remote keybinding 模式和首选 transport,不保存凭据。
## Session 显示名称
Herdr 0.8.2 没有底层 Session rename 命令,因此 Hub 的“重命名”是安全的显示名称:
顶部 Tab 和 Sessions 弹窗显示新名称,attach、重连和工作现场仍绑定原来的 Session ID。
名称会跨终端断线、Hub detach 和 `herdr-hub stop` 保留,并在重新打开 endpoint 时自动
应用。配置保存在 `~/.config/herdr-hub/aliases.json`(遵循 `$XDG_CONFIG_HOME`),
权限为 `0600`;留空保存会删除该 endpoint 的显示名称并恢复 `local/work`、
`host/work` 等默认格式。
## 复制与剪贴板
@@ -147,7 +181,7 @@ Hub 启用 tmux 的 OSC 52 剪贴板透传。Herdr 触发的自动复制,以
可打开占 90% 屏幕的完整选择器。
`refresh` 是 add-only:它添加新发现的 endpoint,不自动删除已有 window,也不会改变
当前焦点。通过 `Ctrl+A &` 或弹窗关闭的 endpoint 会在当前 Hub 生命周期内记为已隐藏,
当前焦点。通过 `Ctrl+A q` 或弹窗关闭的 endpoint 会在当前 Hub 生命周期内记为已隐藏,
刷新和断开终端后重新进入都不会让它自动回来。要恢复它,打开 Sessions 弹窗并重新选择;
也可以执行对应的 `herdr-hub open` 命令。远端通过 SSH 目录重新选择,本地隐藏的运行中
Session 会列在“本机”下。Hub 至少保留一个 Tab;要全部退出请使用 `herdr-hub stop`。
@@ -167,10 +201,11 @@ herdr-hub start
herdr-hub refresh
herdr-hub open [--session NAME]
herdr-hub open --remote TARGET [--session NAME]
[--remote-keybindings local|server]
[--remote-keybindings local|server] [--transport native|ssh]
herdr-hub choose
herdr-hub remote list
herdr-hub remote add TARGET [--remote-keybindings local|server]
[--transport native|ssh]
herdr-hub remote remove TARGET
herdr-hub reconnect [--window ID]
herdr-hub close [--window ID]
@@ -185,10 +220,12 @@ 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 会被记住,不再要求本地 client 持续存活。弹窗先
通过非交互 SSH 执行跨平台的 `herdr session list --json`;Linux/macOS 的非交互 PATH
找不到 Herdr 时,再自动尝试 `$HOME/.local/bin/herdr`。Windows OpenSSH 返回的本地代码页
错误文本也会被容错解码。SSH key/passphrase 应已由 ssh-agent 处理;未知 host key、需要
密码、远端不可达或命令失败时,该主机显示“查询失败 · 单击重试”,不会关闭或卡住整个
弹窗。首次安装或 host-key 确认仍可在实际新建的 Herdr window 中交互完成。
首次使用且尚未登记时,以下情况仍无法自动找回 remote target:
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import fcntl
import json
import os
from pathlib import Path
import tempfile
from typing import Callable, TypeVar
ALIAS_REGISTRY_VERSION = 1
MAX_ALIAS_LENGTH = 48
UpdateResult = TypeVar("UpdateResult")
class AliasRegistryError(RuntimeError):
"""A display-name registry error which is safe to show to the user."""
def normalize_alias(label: str) -> str:
normalized = label.strip()
if len(normalized) > MAX_ALIAS_LENGTH:
raise ValueError(f"display name must be at most {MAX_ALIAS_LENGTH} characters")
if any(ord(character) < 32 or ord(character) == 127 for character in normalized):
raise ValueError("display name must not contain control characters")
return normalized
def default_alias_registry_path() -> Path:
if override := os.environ.get("HERDR_HUB_ALIASES_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" / "aliases.json"
class AliasRegistry:
def __init__(self, path: Path | None = None) -> None:
self.path = path or default_alias_registry_path()
@property
def lock_path(self) -> Path:
return self.path.with_name(f".{self.path.name}.lock")
def load(self) -> dict[str, str]:
if not self.path.exists():
return {}
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise AliasRegistryError(f"cannot read {self.path}: {error}") from error
return _parse_aliases(payload, self.path)
def set(self, endpoint_key: str, label: str) -> bool:
_validate_endpoint_key(endpoint_key)
normalized = normalize_alias(label)
def update(aliases: dict[str, str]) -> tuple[dict[str, str], bool]:
updated = dict(aliases)
if normalized:
changed = updated.get(endpoint_key) != normalized
updated[endpoint_key] = normalized
else:
changed = endpoint_key in updated
updated.pop(endpoint_key, None)
return updated, changed
return self._update(update)
def _update(
self,
callback: Callable[[dict[str, str]], tuple[dict[str, str], UpdateResult]],
) -> UpdateResult:
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)
aliases = self.load()
updated, outcome = callback(aliases)
if updated != aliases:
self._write(updated)
return outcome
except AliasRegistryError:
raise
except OSError as error:
raise AliasRegistryError(f"cannot update {self.path}: {error}") from error
def _write(self, aliases: dict[str, str]) -> None:
payload = {
"version": ALIAS_REGISTRY_VERSION,
"aliases": dict(sorted(aliases.items())),
}
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_aliases(payload: object, path: Path) -> dict[str, str]:
if (
not isinstance(payload, dict)
or payload.get("version") != ALIAS_REGISTRY_VERSION
):
raise AliasRegistryError(f"{path} has an unsupported or missing version")
raw_aliases = payload.get("aliases")
if not isinstance(raw_aliases, dict):
raise AliasRegistryError(f"{path} does not contain an aliases object")
aliases: dict[str, str] = {}
for key, raw_label in raw_aliases.items():
if not isinstance(key, str) or not isinstance(raw_label, str):
raise AliasRegistryError(f"{path} contains an invalid alias entry")
try:
_validate_endpoint_key(key)
label = normalize_alias(raw_label)
except ValueError as error:
raise AliasRegistryError(f"{path}: {error}") from error
if not label:
raise AliasRegistryError(f"{path}: display names must not be empty")
aliases[key] = label
return dict(sorted(aliases.items()))
def _validate_endpoint_key(key: str) -> None:
if not key or len(key) > 1024:
raise ValueError("endpoint key has an invalid length")
if any(ord(character) < 32 or ord(character) == 127 for character in key):
raise ValueError("endpoint key must not contain control characters")
+393 -65
View File
@@ -1,27 +1,45 @@
from __future__ import annotations
import argparse
from contextlib import contextmanager
import curses
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
import fcntl
from functools import wraps
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import stat
import subprocess
import sys
from typing import Callable, Iterable, Sequence
import tempfile
import threading
from typing import Callable, Iterable, Mapping, Sequence
from urllib.parse import quote
from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path
from .domain import validate_session_name
from .remotes import (
RegistryError,
RemoteEntry,
RemoteRegistry,
default_registry_path,
delete_remote_session,
ssh_destination_arguments,
stop_remote_session,
validate_target,
)
from .selector import CatalogWindow, SelectorError, SelectorResult, SessionSelector
from .selector import (
CatalogWindow,
DeleteSessionOutcome,
SelectorError,
SelectorResult,
SessionSelector,
StopSessionOutcome,
)
DEFAULT_SOCKET_NAME = "herdr-hub"
@@ -30,11 +48,9 @@ DEFAULT_SESSION_NAME = "default"
BOOTSTRAP_WINDOW_NAME = "__herdr_hub_bootstrap__"
WINDOW_KEY_OPTION = "@herdr_hub_key"
WINDOW_COMMAND_OPTION = "@herdr_hub_command"
WINDOW_LABEL_OPTION = "@herdr_hub_label"
WINDOW_ENDPOINT_OPTION = "@herdr_hub_endpoint"
WINDOW_SHORT_LABEL_OPTION = "@herdr_hub_short_label"
SESSION_DISMISSED_OPTION = "@herdr_hub_dismissed"
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
class HubError(RuntimeError):
@@ -47,6 +63,7 @@ class Endpoint:
session: str = DEFAULT_SESSION_NAME
target: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
def __post_init__(self) -> None:
if self.kind not in {"local", "remote"}:
@@ -55,10 +72,14 @@ class Endpoint:
if self.kind == "local":
if self.target is not None:
raise ValueError("a local endpoint cannot have a remote target")
if self.remote_transport != "native":
raise ValueError("a local endpoint cannot use a remote transport")
return
validate_remote_target(self.target)
if self.remote_keybindings not in {"local", "server"}:
raise ValueError("remote keybindings must be 'local' or 'server'")
if self.remote_transport not in {"native", "ssh"}:
raise ValueError("remote transport must be 'native' or 'ssh'")
@classmethod
def local(cls, session: str = DEFAULT_SESSION_NAME) -> Endpoint:
@@ -70,12 +91,14 @@ class Endpoint:
target: str,
session: str = DEFAULT_SESSION_NAME,
remote_keybindings: str = "local",
remote_transport: str = "native",
) -> Endpoint:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
remote_transport=remote_transport,
)
@property
@@ -105,10 +128,24 @@ class Endpoint:
label += f"/{self.session}"
return truncate_label(label, 32)
def command(self, herdr_bin: str) -> list[str]:
def command(self, herdr_bin: str, ssh_bin: str = "ssh") -> list[str]:
command = [herdr_bin]
if self.kind == "remote":
assert self.target is not None
if self.remote_transport == "ssh":
remote_command = ["herdr"]
if self.session != DEFAULT_SESSION_NAME:
remote_command.extend(["--session", self.session])
return [
ssh_bin,
"-tt",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
*ssh_destination_arguments(self.target),
shlex.join(remote_command),
]
command.extend(["--remote", self.target])
if self.remote_keybindings != "local":
command.extend(["--remote-keybindings", self.remote_keybindings])
@@ -140,11 +177,13 @@ class ManagedWindow:
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def validate_session_name(name: str) -> None:
if not SESSION_NAME_RE.fullmatch(name):
raise ValueError(
"session name must be 1-64 ASCII letters, numbers, '.', '_' or '-'"
)
def serialized_hub_mutation(method): # type: ignore[no-untyped-def]
@wraps(method)
def wrapper(self, *args, **kwargs): # type: ignore[no-untyped-def]
with self.mutation_lock():
return method(self, *args, **kwargs)
return wrapper
def validate_remote_target(target: str | None) -> None:
@@ -292,6 +331,7 @@ def serialize_endpoint(endpoint: Endpoint) -> str:
"session": endpoint.session,
"target": endpoint.target,
"remote_keybindings": endpoint.remote_keybindings,
"remote_transport": endpoint.remote_transport,
},
ensure_ascii=False,
separators=(",", ":"),
@@ -308,14 +348,17 @@ def deserialize_endpoint(metadata: str, command: str = "") -> Endpoint | None:
session = payload.get("session")
target = payload.get("target")
keybindings = payload.get("remote_keybindings", "local")
transport = payload.get("remote_transport", "native")
if not isinstance(kind, str) or not isinstance(session, str):
raise ValueError
if not isinstance(keybindings, str):
raise ValueError
if not isinstance(transport, str):
raise ValueError
if kind == "local":
return Endpoint.local(session)
if kind == "remote" and isinstance(target, str):
return Endpoint.remote(target, session, keybindings)
return Endpoint.remote(target, session, keybindings, transport)
raise ValueError
except (json.JSONDecodeError, TypeError, ValueError):
pass
@@ -393,20 +436,84 @@ class TmuxHub:
self,
*,
tmux_bin: str,
ssh_bin: str = "ssh",
socket_name: str = DEFAULT_SOCKET_NAME,
session_name: str = DEFAULT_TMUX_SESSION,
config_file: str | None = None,
script_path: str | None = None,
python_bin: str | None = None,
aliases: Mapping[str, str] | None = None,
run: RunFunction = subprocess.run,
) -> None:
self.tmux_bin = tmux_bin
self.ssh_bin = ssh_bin
self.socket_name = socket_name
self.session_name = session_name
self.config_file = config_file
self.script_path = script_path or str(Path(sys.argv[0]).absolute())
self.python_bin = python_bin or sys.executable
self.aliases = dict(aliases or {})
self._run = run
self._local_mutation_lock = threading.RLock()
self._mutation_depth = 0
@property
def mutation_lock_path(self) -> Path:
identity = f"{os.getuid()}\0{self.socket_name}\0{self.session_name}"
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20]
roots = []
if configured_runtime := os.environ.get("XDG_RUNTIME_DIR"):
roots.append(Path(configured_runtime).expanduser())
temporary_root = Path(tempfile.gettempdir())
if temporary_root not in roots:
roots.append(temporary_root)
failures: list[str] = []
for runtime_root in roots:
lock_directory = runtime_root / f"herdr-hub-{os.getuid()}"
try:
lock_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
metadata = lock_directory.lstat()
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
):
failures.append(f"unsafe directory {lock_directory}")
continue
os.chmod(lock_directory, 0o700)
return lock_directory / f"{digest}.lock"
except OSError as error:
failures.append(f"{lock_directory}: {error}")
raise HubError("could not prepare Hub lock directory: " + "; ".join(failures))
@contextmanager
def mutation_lock(self): # type: ignore[no-untyped-def]
"""Serialize state-changing Hub commands across CLI processes."""
with self._local_mutation_lock:
if self._mutation_depth:
self._mutation_depth += 1
try:
yield
finally:
self._mutation_depth -= 1
return
descriptor = os.open(
self.mutation_lock_path,
os.O_CREAT | os.O_RDWR,
0o600,
)
try:
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
self._mutation_depth = 1
try:
yield
finally:
self._mutation_depth = 0
fcntl.flock(descriptor, fcntl.LOCK_UN)
finally:
os.close(descriptor)
def base_command(self) -> list[str]:
command = [self.tmux_bin, "-L", self.socket_name]
@@ -443,6 +550,7 @@ class TmuxHub:
)
return result.returncode == 0
@serialized_hub_mutation
def ensure_server(self) -> bool:
if self.exists():
self.configure()
@@ -478,8 +586,8 @@ class TmuxHub:
"-g",
"status-left",
"#{?#{<:#{client_width},50},"
"#[bold,fg=colour81] S #[default],"
"#[bold,fg=colour81] Sessions #[default]}",
"#[bold#,fg=colour81] S #[default],"
"#[bold#,fg=colour81] Sessions #[default]}",
],
[
"set-option",
@@ -487,7 +595,7 @@ class TmuxHub:
"status-right",
"#{?#{<:#{client_width},80},,"
"#[dim]#{?pane_dead,连接已断开 · C-a R 重连,"
"C-a g choose · & close · r refresh}#[default]}",
"C-a g choose · q close · r refresh}#[default]}",
],
["set-option", "-g", "status-style", "bg=colour235,fg=colour250"],
["set-window-option", "-g", "window-status-separator", ""],
@@ -530,6 +638,10 @@ class TmuxHub:
[self.python_bin, self.script_path, "close", "--window", "#{window_id}"]
)
confirm_close = shlex.join(["run-shell", "-b", close])
# Remove tmux's default/legacy '&' binding when reconfiguring both new
# and already-running Hub servers.
self.run(["unbind-key", "-T", "prefix", "&"], check=False)
self.run(["unbind-key", "-T", "prefix", "Q"], check=False)
binding_commands = [
["bind-key", "-T", "prefix", "C-a", "send-prefix"],
["bind-key", "-T", "prefix", "r", "run-shell", "-b", refresh],
@@ -539,7 +651,7 @@ class TmuxHub:
"prefix",
"g",
"display-popup",
"-E",
"-EE",
"-w",
"90%",
"-h",
@@ -554,7 +666,7 @@ class TmuxHub:
"root",
"MouseDown1StatusLeft",
"display-popup",
"-E",
"-EE",
"-w",
"90%",
"-h",
@@ -568,7 +680,7 @@ class TmuxHub:
"bind-key",
"-T",
"prefix",
"&",
"q",
"confirm-before",
"-p",
"关闭当前 Session Tab? (y/n)",
@@ -578,7 +690,7 @@ class TmuxHub:
for command in binding_commands:
self.run(command)
self.refresh_short_labels()
self.refresh_labels()
def managed_windows(self) -> list[ManagedWindow]:
if not self.exists():
@@ -611,23 +723,42 @@ class TmuxHub:
def window_for_key(self, key: str) -> ManagedWindow | None:
return next((window for window in self.managed_windows() if window.key == key), None)
def refresh_short_labels(self) -> None:
def refresh_labels(self) -> None:
for window in self.managed_windows():
label = (
window.endpoint.short_label
if window.endpoint is not None
else truncate_label(window.name, 32)
)
self.run(
[
"set-option",
"-w",
"-t",
window.window_id,
WINDOW_SHORT_LABEL_OPTION,
label,
]
self._apply_window_label(window)
def _apply_window_label(self, window: ManagedWindow) -> None:
alias = self.aliases.get(window.key)
display_label = truncate_label(alias) if alias else window.name
short_label = truncate_label(alias, 32) if alias else truncate_label(window.name, 32)
if window.endpoint is not None:
display_label = truncate_label(alias) if alias else window.endpoint.label
short_label = (
truncate_label(alias, 32) if alias else window.endpoint.short_label
)
if window.name != display_label:
self.run(["rename-window", "-t", window.window_id, display_label])
self.run(
[
"set-option",
"-w",
"-t",
window.window_id,
WINDOW_SHORT_LABEL_OPTION,
short_label,
]
)
@serialized_hub_mutation
def apply_alias(self, key: str, label: str) -> ManagedWindow | None:
if label:
self.aliases[key] = label
else:
self.aliases.pop(key, None)
window = self.window_for_key(key)
if window is not None:
self._apply_window_label(window)
return self.window_for_key(key)
def dismissed_keys(self) -> set[str]:
result = self.run(
@@ -669,12 +800,17 @@ class TmuxHub:
dismissed.remove(key)
self.set_dismissed_keys(dismissed)
@serialized_hub_mutation
def create_window(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow:
existing = self.window_for_key(endpoint.key)
if existing is not None:
return existing
was_dismissed = endpoint.key in self.dismissed_keys()
self.restore_endpoint(endpoint.key)
command = "exec " + shlex.join(endpoint.command(herdr_bin))
command = "exec " + shlex.join(endpoint.command(herdr_bin, self.ssh_bin))
alias = self.aliases.get(endpoint.key)
display_label = truncate_label(alias) if alias else endpoint.label
short_label = truncate_label(alias, 32) if alias else endpoint.short_label
result = self.run(
[
"new-window",
@@ -685,31 +821,39 @@ class TmuxHub:
"-t",
f"{self.session_name}:",
"-n",
endpoint.label,
display_label,
command,
]
)
window_id = result.stdout.strip()
if not window_id:
if was_dismissed:
self.set_dismissed_keys({*self.dismissed_keys(), endpoint.key})
raise HubError("tmux did not return the new window id")
for option, value in [
(WINDOW_KEY_OPTION, endpoint.key),
(WINDOW_COMMAND_OPTION, command),
(WINDOW_LABEL_OPTION, endpoint.label),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, endpoint.short_label),
]:
self.run(["set-option", "-w", "-t", window_id, option, value])
self.run(["set-window-option", "-t", window_id, "remain-on-exit", "on"])
try:
for option, value in [
(WINDOW_KEY_OPTION, endpoint.key),
(WINDOW_COMMAND_OPTION, command),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, short_label),
]:
self.run(["set-option", "-w", "-t", window_id, option, value])
self.run(["set-window-option", "-t", window_id, "remain-on-exit", "on"])
except HubError:
self.run(["kill-window", "-t", window_id], check=False)
if was_dismissed:
self.set_dismissed_keys({*self.dismissed_keys(), endpoint.key})
raise
return ManagedWindow(
window_id=window_id,
key=endpoint.key,
name=endpoint.label,
name=display_label,
dead=False,
active=False,
endpoint=endpoint,
)
@serialized_hub_mutation
def add_missing(self, endpoints: Sequence[Endpoint], herdr_bin: str) -> list[ManagedWindow]:
existing_keys = {window.key for window in self.managed_windows()}
dismissed_keys = self.dismissed_keys()
@@ -724,6 +868,7 @@ class TmuxHub:
self.remove_bootstrap()
return added
@serialized_hub_mutation
def remove_bootstrap(self) -> None:
result = self.run(
[
@@ -745,6 +890,7 @@ class TmuxHub:
def select_window(self, window_id: str) -> None:
self.run(["select-window", "-t", window_id])
@serialized_hub_mutation
def dismiss_window(self, window_id: str | None = None) -> ManagedWindow:
window_id = window_id or self.current_window_id()
windows = self.managed_windows()
@@ -785,6 +931,7 @@ class TmuxHub:
raise HubError("could not identify the current hub window")
return window_id
@serialized_hub_mutation
def reconnect(self, window_id: str | None = None) -> None:
window_id = window_id or self.current_window_id()
command_result = self.run(
@@ -796,11 +943,59 @@ class TmuxHub:
raise HubError("the selected window is not managed by herdr-hub")
self.run(["respawn-window", "-k", "-t", window_id, command])
@serialized_hub_mutation
def replace_endpoint(
self,
window: ManagedWindow,
endpoint: Endpoint,
herdr_bin: str,
) -> ManagedWindow:
command = "exec " + shlex.join(endpoint.command(herdr_bin, self.ssh_bin))
alias = self.aliases.get(endpoint.key)
short_label = truncate_label(alias, 32) if alias else endpoint.short_label
replacements = [
(WINDOW_COMMAND_OPTION, command),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, short_label),
]
previous: dict[str, str] = {}
for option, _ in replacements:
result = self.run(
["show-options", "-w", "-v", "-t", window.window_id, option],
check=False,
)
if result.returncode == 0:
previous[option] = result.stdout.rstrip("\n")
try:
for option, value in replacements:
self.run(
["set-option", "-w", "-t", window.window_id, option, value]
)
self.run(["respawn-window", "-k", "-t", window.window_id, command])
except HubError:
for option, value in previous.items():
self.run(
["set-option", "-w", "-t", window.window_id, option, value],
check=False,
)
raise
replaced = self.window_for_key(endpoint.key)
if replaced is None:
raise HubError("the selected Session window disappeared during reconnect")
self._apply_window_label(replaced)
return self.window_for_key(endpoint.key) or replaced
@serialized_hub_mutation
def activate_endpoint(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow:
window = self.window_for_key(endpoint.key)
if window is None:
window = self.create_window(endpoint, herdr_bin)
self.remove_bootstrap()
elif window.endpoint is not None and (
window.endpoint.remote_transport != endpoint.remote_transport
or window.endpoint.remote_keybindings != endpoint.remote_keybindings
):
window = self.replace_endpoint(window, endpoint, herdr_bin)
elif window.dead:
self.reconnect(window.window_id)
self.select_window(window.window_id)
@@ -825,6 +1020,7 @@ class TmuxHub:
def display_message(self, message: str) -> None:
self.run(["display-message", message], check=False)
@serialized_hub_mutation
def stop(self) -> None:
if not self.exists():
return
@@ -858,14 +1054,22 @@ def invocation_script_path() -> str:
return str(invoked)
def make_hub(arguments: argparse.Namespace) -> TmuxHub:
def make_hub(
arguments: argparse.Namespace,
*,
load_aliases: bool = True,
) -> TmuxHub:
tmux_bin = resolve_binary("tmux", os.environ.get("HERDR_HUB_TMUX_BIN"))
ssh_bin = os.environ.get("HERDR_HUB_SSH_BIN") or shutil.which("ssh") or "ssh"
aliases = make_alias_registry().load() if load_aliases else {}
return TmuxHub(
tmux_bin=tmux_bin,
ssh_bin=ssh_bin,
socket_name=os.environ.get("HERDR_HUB_SOCKET", DEFAULT_SOCKET_NAME),
session_name=os.environ.get("HERDR_HUB_SESSION", DEFAULT_TMUX_SESSION),
config_file=os.environ.get("HERDR_HUB_TMUX_CONFIG"),
script_path=invocation_script_path(),
aliases=aliases,
)
@@ -885,16 +1089,24 @@ def make_registry() -> RemoteRegistry:
return RemoteRegistry(default_registry_path())
def make_alias_registry() -> AliasRegistry:
return AliasRegistry(default_alias_registry_path())
def remember_remote_endpoints(endpoints: Iterable[Endpoint]) -> str | None:
entries = [
RemoteEntry(endpoint.target, endpoint.remote_keybindings)
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
for endpoint in endpoints
if endpoint.kind == "remote" and endpoint.target is not None
]
if not entries:
return None
try:
make_registry().upsert_many(entries)
make_registry().remember_many(entries)
except RegistryError as error:
return f"remote registry: {error}"
return None
@@ -911,6 +1123,14 @@ def catalog_windows(windows: Iterable[ManagedWindow]) -> list[CatalogWindow]:
kind=window.endpoint.kind if window.endpoint is not None else None,
target=window.endpoint.target if window.endpoint is not None else None,
session=window.endpoint.session if window.endpoint is not None else None,
default_label=(
window.endpoint.label if window.endpoint is not None else window.name
),
remote_transport=(
window.endpoint.remote_transport
if window.endpoint is not None
else "native"
),
)
for window in windows
]
@@ -926,8 +1146,7 @@ def command_start(arguments: argparse.Namespace) -> int:
added = hub.add_missing(report.endpoints, herdr_bin)
windows = hub.managed_windows()
if not windows:
if created:
hub.stop()
hub.stop()
detail = "; ".join(report.warnings)
suffix = f" ({detail})" if detail else ""
raise HubError(
@@ -953,7 +1172,10 @@ def command_refresh(arguments: argparse.Namespace) -> int:
message = f"herdr-hub: added {len(added)} session(s)"
if report.warnings:
message += f"; {report.warnings[0]}"
if inside_socket(hub.socket_name):
has_windows = bool(hub.managed_windows())
if not has_windows:
hub.stop()
if inside_socket(hub.socket_name) and has_windows:
hub.display_message(message)
else:
print(message)
@@ -968,6 +1190,7 @@ def command_open(arguments: argparse.Namespace) -> int:
arguments.remote,
arguments.session,
arguments.remote_keybindings,
arguments.transport,
)
if arguments.remote is not None
else Endpoint.local(arguments.session)
@@ -975,9 +1198,17 @@ def command_open(arguments: argparse.Namespace) -> int:
except ValueError as error:
raise HubError(str(error)) from error
if endpoint.kind == "remote":
warning = remember_remote_endpoints([endpoint])
if warning:
print(f"herdr-hub: warning: {warning}", file=sys.stderr)
assert endpoint.target is not None
try:
make_registry().upsert(
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
)
except RegistryError as error:
print(f"herdr-hub: warning: remote registry: {error}", file=sys.stderr)
hub = make_hub(arguments)
hub.ensure_server()
hub.activate_endpoint(endpoint, herdr_bin)
@@ -991,21 +1222,82 @@ def command_choose(arguments: argparse.Namespace) -> int:
raise HubError("herdr-hub is not running")
windows = hub.managed_windows()
local_report = discover_local_sessions(resolve_herdr())
remember_remote_endpoints(
registry_warning = remember_remote_endpoints(
window.endpoint for window in windows if window.endpoint is not None
)
alias_registry = make_alias_registry()
remote_registry = make_registry()
ssh_bin = resolve_ssh()
def rename_session(endpoint_key: str, alias: str) -> None:
alias_registry.set(endpoint_key, alias)
if hub.apply_alias(endpoint_key, alias) is None:
raise HubError("the selected Session is no longer open")
def delete_session(target: str, session: str) -> DeleteSessionOutcome:
delete_remote_session(ssh_bin, RemoteEntry(target), session)
endpoint_key = Endpoint.remote(target, session).key
warning: str | None = None
try:
alias_registry.set(endpoint_key, "")
except AliasRegistryError as error:
warning = f"Session 已删除,但显示名称清理失败:{error}"
hub.aliases.pop(endpoint_key, None)
return DeleteSessionOutcome(warning)
def stop_session(
window_id: str,
target: str,
session: str,
) -> StopSessionOutcome:
stop_remote_session(ssh_bin, RemoteEntry(target), session)
warning: str | None = None
tab_closed = False
try:
hub.dismiss_window(window_id)
tab_closed = True
except HubError as error:
warning = f"Session 已停止,但 Tab 未关闭:{error}"
updated = catalog_windows(hub.managed_windows())
if not tab_closed:
updated = [
replace(window, dead=True)
if window.window_id == window_id
else window
for window in updated
]
return StopSessionOutcome(tuple(updated), tab_closed, warning)
try:
selector = SessionSelector(
catalog_windows(windows),
make_registry(),
resolve_ssh(),
remote_registry,
ssh_bin,
local_sessions=(endpoint.session for endpoint in local_report.endpoints),
aliases=hub.aliases,
rename_handler=rename_session,
delete_session_handler=delete_session,
stop_session_handler=stop_session,
)
startup_warnings = [*local_report.warnings]
if registry_warning:
startup_warnings.append(registry_warning)
if startup_warnings:
selector.message = "; ".join(startup_warnings)
result = selector.run()
except (RegistryError, SelectorError) as error:
raise HubError(str(error)) from error
if result is None:
return 0
if result.kind == "rename":
assert result.endpoint_key is not None and result.alias is not None
rename_session(result.endpoint_key, result.alias)
window = hub.window_for_key(result.endpoint_key)
assert window is not None
action = "renamed" if result.alias else "restored the default name for"
hub.display_message(f"herdr-hub: {action} {window.name}")
return 0
if result.kind == "close":
assert result.window_id is not None
hub.dismiss_window(result.window_id)
@@ -1025,7 +1317,18 @@ def command_choose(arguments: argparse.Namespace) -> int:
result.target,
result.session,
result.remote_keybindings,
result.remote_transport,
)
try:
remote_registry.upsert(
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
)
except RegistryError as error:
hub.display_message(f"herdr-hub: remote registry: {error}")
hub.activate_endpoint(endpoint, resolve_herdr())
return 0
@@ -1039,10 +1342,16 @@ def command_remote(arguments: argparse.Namespace) -> int:
print("no registered remote targets")
return 0
for entry in entries:
print(f"{entry.target}\t{entry.remote_keybindings}")
print(
f"{entry.target}\t{entry.remote_keybindings}\t{entry.remote_transport}"
)
return 0
if action == "add":
entry = RemoteEntry(arguments.target, arguments.remote_keybindings)
entry = RemoteEntry(
arguments.target,
arguments.remote_keybindings,
arguments.transport,
)
changed = registry.upsert(entry)
print(f"{'registered' if changed else 'already registered'} {entry.target}")
return 0
@@ -1054,7 +1363,7 @@ def command_remote(arguments: argparse.Namespace) -> int:
def command_reconnect(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if not hub.exists():
raise HubError("herdr-hub is not running")
hub.reconnect(arguments.window)
@@ -1064,7 +1373,7 @@ def command_reconnect(arguments: argparse.Namespace) -> int:
def command_close(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if not hub.exists():
raise HubError("herdr-hub is not running")
try:
@@ -1083,7 +1392,7 @@ def command_close(arguments: argparse.Namespace) -> int:
def command_stop(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if hub.exists():
hub.stop()
print("stopped herdr-hub; Herdr servers were left running")
@@ -1119,6 +1428,13 @@ def command_doctor(arguments: argparse.Namespace) -> int:
except RegistryError as error:
print(f"remotes: invalid ({error})")
failures += 1
alias_registry = make_alias_registry()
try:
aliases = alias_registry.load()
print(f"aliases: {len(aliases)} configured ({alias_registry.path})")
except AliasRegistryError as error:
print(f"aliases: invalid ({error})")
failures += 1
proc_root = configured_proc_root()
print(f"proc: {proc_root} ({'available' if proc_root.is_dir() else 'missing'})")
@@ -1153,6 +1469,12 @@ def build_parser() -> argparse.ArgumentParser:
choices=["local", "server"],
default="local",
)
open_parser.add_argument(
"--transport",
choices=["native", "ssh"],
default="native",
help="remote attach transport; Windows targets require ssh",
)
subparsers.add_parser("choose", help="browse and open local or remote sessions")
remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets")
@@ -1165,6 +1487,12 @@ def build_parser() -> argparse.ArgumentParser:
choices=["local", "server"],
default="local",
)
remote_add_parser.add_argument(
"--transport",
choices=["native", "ssh"],
default="native",
help="preferred attach transport; use ssh for Windows targets",
)
remote_remove_parser = remote_subparsers.add_parser(
"remove", help="forget a remote target"
)
@@ -1199,7 +1527,7 @@ def main(argv: Sequence[str] | None = None) -> int:
}
try:
return handlers[command](arguments)
except (HubError, RegistryError, ValueError) as error:
except (AliasRegistryError, HubError, RegistryError, ValueError) as error:
print(f"herdr-hub: {error}", file=sys.stderr)
return 1
except KeyboardInterrupt:
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import re
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def is_valid_session_name(name: str) -> bool:
return SESSION_NAME_RE.fullmatch(name) is not None
def validate_session_name(name: str) -> None:
if not is_valid_session_name(name):
raise ValueError(
"session name must be 1-64 ASCII letters, numbers, '.', '_' or '-'"
)
+303 -27
View File
@@ -12,8 +12,11 @@ import time
from typing import Iterable, Sequence
from urllib.parse import SplitResult, unquote, urlsplit
from .domain import is_valid_session_name
REGISTRY_VERSION = 1
REMOTE_DIRECT_COMMAND = "herdr session list --json"
REMOTE_LIST_SCRIPT = (
'if [ -x "$HOME/.local/bin/herdr" ]; then '
'exec "$HOME/.local/bin/herdr" session list --json; '
@@ -31,15 +34,26 @@ class RemoteTargetError(ValueError):
"""A remote target cannot be represented safely as an SSH command."""
class RemoteSessionDeleteError(RuntimeError):
"""A stopped remote Session could not be deleted."""
class RemoteSessionStopError(RuntimeError):
"""A running remote Session could not be stopped."""
@dataclass(frozen=True)
class RemoteEntry:
target: str
remote_keybindings: str = "local"
remote_transport: str = "native"
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'")
if self.remote_transport not in {"native", "ssh"}:
raise ValueError("remote transport must be 'native' or 'ssh'")
@dataclass(frozen=True)
@@ -53,6 +67,7 @@ class RemoteQueryResult:
entry: RemoteEntry
sessions: tuple[RemoteSession, ...] = ()
error: str | None = None
platform: str = "unknown"
def validate_target(target: str) -> None:
@@ -147,6 +162,38 @@ class RemoteRegistry:
return self._update(update)
def remember_many(self, entries: Iterable[RemoteEntry]) -> int:
"""Add discovered targets without overwriting explicit preferences.
SSH transport is sticky so legacy Windows windows can teach the registry
that a target must not be reopened through native ``--remote`` mode.
"""
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:
existing = by_target.get(entry.target)
if existing is None:
by_target[entry.target] = entry
changed += 1
elif (
existing.remote_transport != "ssh"
and entry.remote_transport == "ssh"
):
by_target[entry.target] = RemoteEntry(
existing.target,
existing.remote_keybindings,
"ssh",
)
changed += 1
return sorted(by_target.values(), key=lambda item: item.target), changed
return self._update(update)
def remove(self, target: str) -> bool:
validate_target(target)
@@ -178,6 +225,7 @@ class RemoteRegistry:
{
"target": entry.target,
"remote_keybindings": entry.remote_keybindings,
"remote_transport": entry.remote_transport,
}
for entry in entries
],
@@ -224,10 +272,15 @@ def _parse_registry(payload: object, path: Path) -> list[RemoteEntry]:
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):
transport = raw.get("remote_transport", "native")
if (
not isinstance(target, str)
or not isinstance(keybindings, str)
or not isinstance(transport, str)
):
raise RegistryError(f"{path}: remotes[{index}] has invalid fields")
try:
entry = RemoteEntry(target, keybindings)
entry = RemoteEntry(target, keybindings, transport)
except ValueError as error:
raise RegistryError(f"{path}: remotes[{index}]: {error}") from error
if entry.target in seen:
@@ -260,6 +313,7 @@ def remote_session_command(
entry: RemoteEntry,
*,
connect_timeout: int = 4,
posix_fallback: bool = False,
) -> list[str]:
return [
ssh_bin,
@@ -273,10 +327,182 @@ def remote_session_command(
"-o",
f"ConnectTimeout={connect_timeout}",
*ssh_destination_arguments(entry.target),
REMOTE_LIST_SCRIPT,
REMOTE_LIST_SCRIPT if posix_fallback else REMOTE_DIRECT_COMMAND,
]
def remote_delete_session_command(
ssh_bin: str,
entry: RemoteEntry,
session: str,
*,
connect_timeout: int = 4,
posix_fallback: bool = False,
) -> list[str]:
if not is_valid_session_name(session):
raise ValueError("invalid remote Session name")
direct_command = f"herdr session delete --json -- {session}"
fallback_command = (
'if [ -x "$HOME/.local/bin/herdr" ]; then '
f'exec "$HOME/.local/bin/herdr" session delete --json -- {session}; '
"elif command -v herdr >/dev/null 2>&1; then "
f"exec herdr session delete --json -- {session}; "
"else echo 'herdr is not installed on the remote host' >&2; exit 127; fi"
)
return [
ssh_bin,
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"ConnectionAttempts=1",
"-o",
f"ConnectTimeout={connect_timeout}",
*ssh_destination_arguments(entry.target),
fallback_command if posix_fallback else direct_command,
]
def remote_stop_session_command(
ssh_bin: str,
entry: RemoteEntry,
session: str,
*,
connect_timeout: int = 4,
posix_fallback: bool = False,
) -> list[str]:
if not is_valid_session_name(session):
raise ValueError("invalid remote Session name")
direct_command = f"herdr session stop -- {session}"
fallback_command = (
'if [ -x "$HOME/.local/bin/herdr" ]; then '
f'exec "$HOME/.local/bin/herdr" session stop -- {session}; '
"elif command -v herdr >/dev/null 2>&1; then "
f"exec herdr session stop -- {session}; "
"else echo 'herdr is not installed on the remote host' >&2; exit 127; fi"
)
return [
ssh_bin,
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"ConnectionAttempts=1",
"-o",
f"ConnectTimeout={connect_timeout}",
*ssh_destination_arguments(entry.target),
fallback_command if posix_fallback else direct_command,
]
def delete_remote_session(
ssh_bin: str,
entry: RemoteEntry,
session: str,
*,
timeout: float = 8.0,
) -> None:
"""Delete one stopped Herdr Session through a non-interactive SSH call."""
result: subprocess.CompletedProcess[bytes] | None = None
for posix_fallback in (False, True):
command = remote_delete_session_command(
ssh_bin,
entry,
session,
posix_fallback=posix_fallback,
)
try:
result = subprocess.run(
command,
capture_output=True,
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as error:
raise RemoteSessionDeleteError(
f"remote Session delete timed out after {timeout:g}s"
) from error
except OSError as error:
raise RemoteSessionDeleteError(
f"could not start remote Session delete: {error}"
) from error
if result.returncode != 127 or posix_fallback:
break
assert result is not None
stdout = decode_remote_output(result.stdout)
stderr = decode_remote_output(result.stderr)
if result.returncode != 0:
detail = (stderr or stdout or f"ssh exited with status {result.returncode}").strip()
raise RemoteSessionDeleteError(" ".join(detail.splitlines()))
try:
payload = json.loads(stdout)
except json.JSONDecodeError as error:
raise RemoteSessionDeleteError(
f"invalid Herdr delete response: {error}"
) from error
if not isinstance(payload, dict) or payload.get("deleted") is not True:
raise RemoteSessionDeleteError("Herdr did not confirm Session deletion")
def stop_remote_session(
ssh_bin: str,
entry: RemoteEntry,
session: str,
*,
timeout: float = 8.0,
) -> None:
"""Stop one running Herdr Session through a non-interactive SSH call."""
result: subprocess.CompletedProcess[bytes] | None = None
for posix_fallback in (False, True):
command = remote_stop_session_command(
ssh_bin,
entry,
session,
posix_fallback=posix_fallback,
)
try:
result = subprocess.run(
command,
capture_output=True,
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as error:
raise RemoteSessionStopError(
f"remote Session stop timed out after {timeout:g}s"
) from error
except OSError as error:
raise RemoteSessionStopError(
f"could not start remote Session stop: {error}"
) from error
if result.returncode != 127 or posix_fallback:
break
assert result is not None
stdout = decode_remote_output(result.stdout)
stderr = decode_remote_output(result.stderr)
if result.returncode != 0:
detail = (stderr or stdout or f"ssh exited with status {result.returncode}").strip()
raise RemoteSessionStopError(" ".join(detail.splitlines()))
def decode_remote_output(raw: bytes) -> str:
if not raw:
return ""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
try:
return raw.decode("gb18030")
except UnicodeDecodeError:
return raw.decode("utf-8", errors="replace")
def parse_remote_session_result(
entry: RemoteEntry,
returncode: int,
@@ -309,14 +535,40 @@ def parse_remote_session_result(
sessions.values(),
key=lambda session: (session.name != "default", session.name),
)
return RemoteQueryResult(entry, sessions=tuple(ordered))
return RemoteQueryResult(
entry,
sessions=tuple(ordered),
platform=detect_remote_platform(raw_sessions),
)
def detect_remote_platform(raw_sessions: list[object]) -> str:
saw_posix_path = False
for raw in raw_sessions:
if not isinstance(raw, dict):
continue
for field in ("session_dir", "socket_path"):
value = raw.get(field)
if not isinstance(value, str):
continue
if (
len(value) >= 3
and value[0].isalpha()
and value[1] == ":"
and value[2] in {"\\", "/"}
):
return "windows"
if value.startswith("/"):
saw_posix_path = True
return "posix" if saw_posix_path else "unknown"
@dataclass
class _RemoteJob:
entry: RemoteEntry
process: subprocess.Popen[str]
process: subprocess.Popen[bytes]
started_at: float
posix_fallback: bool = False
class RemoteQueryPool:
@@ -372,7 +624,17 @@ class RemoteQueryPool:
continue
if timed_out and returncode is None:
job.process.kill()
stdout, stderr = job.process.communicate()
try:
stdout_bytes, stderr_bytes = job.process.communicate()
except (OSError, subprocess.SubprocessError) as error:
self._active.pop(target, None)
finished.append(
RemoteQueryResult(
job.entry,
error=f"remote query output could not be read: {error}",
)
)
continue
self._active.pop(target, None)
if timed_out:
finished.append(
@@ -381,13 +643,17 @@ class RemoteQueryPool:
error=f"remote query timed out after {self.query_timeout:g}s",
)
)
elif job.process.returncode == 127 and not job.posix_fallback:
fallback = self._start_job(job.entry, posix_fallback=True)
if fallback is not None:
self._active[target] = fallback
else:
finished.append(
parse_remote_session_result(
job.entry,
job.process.returncode,
stdout,
stderr,
decode_remote_output(stdout_bytes),
decode_remote_output(stderr_bytes),
)
)
self._start_pending()
@@ -402,27 +668,37 @@ class RemoteQueryPool:
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())
job = self._start_job(entry)
if job is not None:
self._active[entry.target] = job
def _start_job(
self,
entry: RemoteEntry,
*,
posix_fallback: bool = False,
) -> _RemoteJob | None:
try:
process = subprocess.Popen(
remote_session_command(
self.ssh_bin,
entry,
connect_timeout=self.connect_timeout,
posix_fallback=posix_fallback,
),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except (OSError, ValueError) as error:
self._completed.append(
RemoteQueryResult(entry, error=f"cannot start remote query: {error}")
)
return None
return _RemoteJob(entry, process, time.monotonic(), posix_fallback)
@staticmethod
def _stop_process(process: subprocess.Popen[str]) -> None:
def _stop_process(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
process.communicate()
return
+643 -37
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, replace
import curses
import re
from typing import Iterable
import unicodedata
from typing import Callable, Iterable
from .aliases import MAX_ALIAS_LENGTH, normalize_alias
from .domain import is_valid_session_name
from .remotes import (
RegistryError,
RemoteEntry,
@@ -14,7 +17,71 @@ from .remotes import (
)
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
RenameHandler = Callable[[str, str], None]
@dataclass(frozen=True)
class DeleteSessionOutcome:
warning: str | None = None
def terminal_display_width(text: str) -> int:
"""Return the number of terminal cells occupied by Unicode text."""
width = 0
for character in text:
if unicodedata.combining(character):
continue
if unicodedata.category(character).startswith("C"):
continue
width += 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1
return width
def truncate_terminal_text(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
result: list[str] = []
width = 0
for character in text:
character_width = terminal_display_width(character)
if width + character_width > max_width:
break
result.append(character)
width += character_width
return "".join(result)
def terminal_text_tail(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
start = len(text)
width = 0
for index in range(len(text) - 1, -1, -1):
character_width = terminal_display_width(text[index])
if width + character_width > max_width:
break
start = index
width += character_width
return text[start:]
def pad_terminal_text(text: str, width: int) -> str:
truncated = truncate_terminal_text(text, width)
return truncated + " " * max(0, width - terminal_display_width(truncated))
def input_display_line(prompt: str, value: str, width: int) -> str:
prefix = f"{prompt}: "
if terminal_display_width(prefix) >= width:
return truncate_terminal_text(prefix, width)
value_width = width - terminal_display_width(prefix)
if terminal_display_width(value) <= value_width:
visible_value = value
elif value_width <= 1:
visible_value = "…"[:value_width]
else:
visible_value = "…" + terminal_text_tail(value, value_width - 1)
return prefix + visible_value
class SelectorError(RuntimeError):
@@ -31,6 +98,27 @@ class CatalogWindow:
kind: str | None = None
target: str | None = None
session: str | None = None
default_label: str | None = None
remote_transport: str = "native"
@dataclass(frozen=True)
class StopSessionOutcome:
windows: tuple[CatalogWindow, ...]
tab_closed: bool = True
warning: str | None = None
DeleteSessionHandler = Callable[[str, str], DeleteSessionOutcome | None]
StopSessionHandler = Callable[[str, str, str], StopSessionOutcome | list[CatalogWindow]]
@dataclass(frozen=True)
class _PendingAction:
kind: str
target: str
session: str
window_id: str | None = None
@dataclass(frozen=True)
@@ -41,6 +129,9 @@ class SelectorResult:
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
endpoint_key: str | None = None
alias: str | None = None
@classmethod
def window(cls, window_id: str, *, dead: bool) -> SelectorResult:
@@ -54,18 +145,24 @@ class SelectorResult:
def local(cls, session: str) -> SelectorResult:
return cls(kind="local", session=session)
@classmethod
def rename(cls, endpoint_key: str, alias: str) -> SelectorResult:
return cls(kind="rename", endpoint_key=endpoint_key, alias=alias)
@classmethod
def remote(
cls,
target: str,
session: str,
remote_keybindings: str,
remote_transport: str = "native",
) -> SelectorResult:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
remote_transport=remote_transport,
)
@@ -81,6 +178,9 @@ class PickerRow:
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
endpoint_key: str | None = None
deletable: bool = False
def build_main_rows(
@@ -118,7 +218,7 @@ def build_main_rows(
{
session
for session in local_sessions
if SESSION_NAME_RE.fullmatch(session) and session not in open_local_sessions
if is_valid_session_name(session) and session not in open_local_sessions
},
key=lambda name: (name != "default", name),
)
@@ -148,6 +248,36 @@ def build_main_rows(
window_id=active_window.window_id,
)
)
if (
active_window.kind == "remote"
and active_window.target is not None
and active_window.session is not None
):
rows.append(
PickerRow(
"stop-active",
"stop-session",
f"■ 关闭并停止当前 Session:{active_window.label}",
(
"远端任务会中断"
if can_close
else "远端任务会中断;最后一个 Tab 将保留为断线状态"
),
selectable=True,
window_id=active_window.window_id,
target=active_window.target,
session=active_window.session,
)
)
if window_list:
rows.append(
PickerRow(
"manage-sessions",
"manage-sessions",
"✎ 管理 Session 显示名称…",
"重命名或恢复默认",
)
)
remote_windows = {
(window.target, window.session): window
@@ -157,21 +287,38 @@ def build_main_rows(
and window.session is not None
}
for entry in entry_list:
result = results.get(entry.target)
preferred_transport = (
"ssh"
if entry.remote_transport == "ssh"
or (result is not None and result.platform == "windows")
or any(
window.target == entry.target and window.remote_transport == "ssh"
for window in window_list
)
else "native"
)
platform_detail = (
"Windows · SSH 模式"
if result is not None and result.platform == "windows"
else "SSH TTY 模式"
if preferred_transport == "ssh"
else "远端"
)
rows.append(
PickerRow(
f"heading:{entry.target}",
"heading",
entry.target,
"远端",
platform_detail,
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 is_valid_session_name(session.name)
}
if result is not None and result.error is None
else {}
@@ -185,20 +332,45 @@ def build_main_rows(
key=lambda name: (name != "default", name),
):
window = remote_windows.get((entry.target, session_name))
if window is not None:
transport = preferred_transport
# Automatic transport reconciliation is intentionally one-way.
# A confirmed/manual SSH window must never be downgraded merely
# because platform detection is loading, failed or inconclusive.
if window is not None and not (
transport == "ssh" and window.remote_transport != "ssh"
):
rows.append(_window_row(window))
continue
session = session_by_name[session_name]
assert session is not None
running = session.running if session is not None else not window.dead
if window is not None and transport == "ssh":
symbol = "↻"
detail = "Windows · 切换为 SSH 模式"
else:
symbol = "○" if running else "■"
if transport == "ssh":
detail = (
"Windows · SSH 模式 · 单击打开"
if running
else "Windows · 已停止 · Enter 启动 · d 删除"
)
else:
detail = (
"远端运行 · 单击打开"
if running
else "已停止 · Enter 启动 · d 删除"
)
rows.append(
PickerRow(
f"remote:{entry.target}:{session_name}",
"remote",
f"{'○' if session.running else '■'} {session_name}",
"远端运行 · 单击打开" if session.running else "已停止 · 单击启动",
f"{symbol} {session_name}",
detail,
target=entry.target,
session=session_name,
remote_keybindings=entry.remote_keybindings,
remote_transport=transport,
deletable=not running and window is None,
)
)
@@ -238,6 +410,7 @@ def build_main_rows(
entry.target,
target=entry.target,
remote_keybindings=entry.remote_keybindings,
remote_transport=preferred_transport,
)
)
@@ -254,7 +427,14 @@ def build_main_rows(
else:
rows.extend(
[
PickerRow("add-remote", "add", "+ 添加远端…", "SSH target"),
PickerRow("add-remote", "add", "+ 添加远端…", "自动/native 模式"),
PickerRow(
"add-ssh-remote",
"add-ssh",
"+ 添加 Windows/SSH 远端…",
"强制 SSH TTY 模式",
remote_transport="ssh",
),
PickerRow("manage-remotes", "manage", "⚙ 管理远端…"),
]
)
@@ -294,11 +474,25 @@ class SessionSelector:
registry: RemoteRegistry,
ssh_bin: str,
local_sessions: Iterable[str] = (),
aliases: dict[str, str] | None = None,
rename_handler: RenameHandler | None = None,
delete_session_handler: DeleteSessionHandler | None = None,
stop_session_handler: StopSessionHandler | None = None,
) -> None:
self.windows = list(windows)
self.local_sessions = list(local_sessions)
self.aliases = dict(aliases or {})
self.rename_handler = rename_handler
self.delete_session_handler = delete_session_handler
self.stop_session_handler = stop_session_handler
self.registry = registry
self.pool = RemoteQueryPool(ssh_bin)
self.action_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="herdr-hub-action",
)
self.action_future: Future[object] | None = None
self.pending_action: _PendingAction | None = None
self.entries: dict[str, RemoteEntry] = {}
self.results: dict[str, RemoteQueryResult] = {}
self.registry_error: str | None = None
@@ -309,6 +503,11 @@ class SessionSelector:
self.view = "main"
self.input_value = ""
self.input_target: str | None = None
self.input_session: str | None = None
self.input_window_id: str | None = None
self.input_key: str | None = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self._drawn_rows: dict[int, PickerRow] = {}
self._load_registry()
@@ -329,6 +528,7 @@ class SessionSelector:
raise SelectorError(f"could not initialize the terminal selector: {error}") from error
finally:
self.pool.close()
self.action_executor.shutdown(wait=False, cancel_futures=True)
def _curses_main(self, screen) -> SelectorResult | None: # type: ignore[no-untyped-def]
screen.keypad(True)
@@ -347,6 +547,7 @@ class SessionSelector:
while True:
for result in self.pool.poll():
self.results[result.entry.target] = result
self._poll_action()
rows = self._rows()
filtered = filter_rows(rows, self.query) if self.view == "main" else rows
self._normalize_selection(filtered)
@@ -361,6 +562,35 @@ class SessionSelector:
return outcome if isinstance(outcome, SelectorResult) else None
def _rows(self) -> list[PickerRow]:
if self.view == "manage-sessions":
rows = [
PickerRow(
"manage-sessions-heading",
"heading",
"管理 Session 显示名称",
"仅影响 Hub,不修改底层 Session ID",
selectable=False,
)
]
rows.extend(
PickerRow(
f"rename:{window.key}",
"rename",
f"✎ {window.label}",
(
f"原名 {window.default_label}"
if window.key in self.aliases
else "使用默认名称"
),
endpoint_key=window.key,
)
for window in sorted(
self.windows,
key=lambda item: (not item.active, item.label.casefold()),
)
)
rows.append(PickerRow("manage-sessions-back", "back", "← 返回 Sessions"))
return rows
if self.view == "manage":
rows = [PickerRow("manage-heading", "heading", "管理远端", selectable=False)]
rows.extend(
@@ -368,7 +598,11 @@ class SessionSelector:
f"remove:{entry.target}",
"remove",
f"− {entry.target}",
"单击后确认移除",
(
"SSH TTY · 单击后确认移除"
if entry.remote_transport == "ssh"
else "native/自动 · 单击后确认移除"
),
target=entry.target,
)
for entry in sorted(self.entries.values(), key=lambda item: item.target)
@@ -380,11 +614,83 @@ class SessionSelector:
if self.view == "confirm-remove":
target = self.input_target or ""
return [
PickerRow("confirm-heading", "heading", f"移除 {target}?", "不会关闭已有窗口或远端 server", selectable=False),
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"}:
if self.view == "confirm-delete-session":
target = self.input_target or ""
session = self.input_session or ""
return [
PickerRow(
"delete-session-heading",
"heading",
f"永久删除 {target}/{session}?",
"只允许删除已停止的 Session",
selectable=False,
),
PickerRow(
"delete-session-warning",
"empty",
"工作现场和历史数据将无法恢复",
selectable=False,
),
PickerRow(
"delete-session-yes",
"delete-session-yes",
"确认删除",
target=target,
session=session,
),
PickerRow("delete-session-no", "back", "取消"),
]
if self.view == "confirm-stop-session":
target = self.input_target or ""
session = self.input_session or ""
closes_tab = len(self.windows) > 1
return [
PickerRow(
"stop-session-heading",
"heading",
f"关闭并停止 {target}/{session}?",
(
"Hub Tab 将关闭,远端任务会中断"
if closes_tab
else "最后一个 Tab 将保留为断线状态,远端任务会中断"
),
selectable=False,
),
PickerRow(
"stop-session-yes",
"stop-session-yes",
"确认停止",
window_id=self.input_window_id,
target=target,
session=session,
),
PickerRow("stop-session-no", "back", "取消"),
]
if self.view in {"working-delete-session", "working-stop-session"}:
action = self.pending_action
target = action.target if action is not None else ""
session = action.session if action is not None else ""
verb = "删除" if self.view == "working-delete-session" else "停止"
return [
PickerRow(
"working-heading",
"heading",
f"正在{verb} {target}/{session}",
"SSH 操作进行中,请稍候…",
selectable=False,
)
]
if self.view in {"input-add", "input-new", "input-rename"}:
return []
return build_main_rows(
self.windows,
@@ -396,7 +702,11 @@ class SessionSelector:
)
def _handle_key(self, key, rows: list[PickerRow]): # type: ignore[no-untyped-def]
if self.view in {"input-add", "input-new"}:
if self.action_future is not None:
if key == "\x1b":
self.message = "远端操作正在进行,完成后可继续管理"
return None
if self.view in {"input-add", "input-new", "input-rename"}:
return self._handle_input_key(key)
if key == curses.KEY_MOUSE:
return self._handle_mouse(rows)
@@ -414,6 +724,17 @@ class SessionSelector:
return None
if key in {curses.KEY_ENTER, "\n", "\r"}:
return self._activate(self._selected_row(rows))
if key == "d" and self.view == "confirm-delete-session":
row = next(
(item for item in rows if item.kind == "delete-session-yes"),
None,
)
return self._activate(row)
if key == "d" and self.view == "main":
row = self._selected_row(rows)
if row is not None and row.deletable:
self._show_delete_session_confirmation(row)
return None
if key == "\x12" and self.view == "main":
self.results.clear()
self.pool.enqueue_many(self.entries.values())
@@ -441,17 +762,26 @@ class SessionSelector:
def _handle_input_key(self, key): # type: ignore[no-untyped-def]
if key == "\x1b":
if self.view == "input-rename":
self._show_manage_sessions()
return None
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)
if self.view == "input-rename":
return self._finish_rename(value)
return self._finish_new(value)
if key == "\x15" and self.view == "input-rename":
self.input_value = ""
return None
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:
limit = MAX_ALIAS_LENGTH if self.view == "input-rename" else 512
if isinstance(key, str) and key.isprintable() and len(self.input_value) < limit:
self.input_value += key
return None
@@ -466,11 +796,12 @@ class SessionSelector:
if state & getattr(curses, "BUTTON5_PRESSED", 0):
self._move(rows, 3)
return None
clicked = (
getattr(curses, "BUTTON1_CLICKED", 0)
activated = (
getattr(curses, "BUTTON1_RELEASED", 0)
| getattr(curses, "BUTTON1_CLICKED", 0)
| getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0)
)
if state & clicked:
if state & activated:
row = self._drawn_rows.get(y)
if row is not None and row.selectable:
self.selected_key = row.key
@@ -495,6 +826,7 @@ class SessionSelector:
row.target,
row.session,
row.remote_keybindings,
row.remote_transport,
)
if row.kind == "retry":
assert row.target is not None
@@ -504,30 +836,74 @@ class SessionSelector:
elif row.kind == "new":
self.view = "input-new"
self.input_target = row.target
self.input_remote_keybindings = row.remote_keybindings
self.input_remote_transport = row.remote_transport
self.input_value = ""
self.message = ""
elif row.kind == "add":
elif row.kind in {"add", "add-ssh"}:
self.view = "input-add"
self.input_target = None
self.input_remote_keybindings = "local"
self.input_remote_transport = row.remote_transport
self.input_value = ""
self.message = ""
elif row.kind == "manage":
self.view = "manage"
self.query = ""
self.selected_key = None
elif row.kind == "manage-sessions":
self._show_manage_sessions()
elif row.kind == "rename":
assert row.endpoint_key is not None
self.view = "input-rename"
self.input_key = row.endpoint_key
self.input_target = next(
(
window.default_label
for window in self.windows
if window.key == row.endpoint_key
),
row.endpoint_key,
)
self.input_value = self.aliases.get(row.endpoint_key, "")
self.message = ""
elif row.kind == "remove":
self.view = "confirm-remove"
self.input_target = row.target
self.selected_key = "confirm-no"
elif row.kind == "stop-session":
assert (
row.window_id is not None
and row.target is not None
and row.session is not None
)
self.view = "confirm-stop-session"
self.input_window_id = row.window_id
self.input_target = row.target
self.input_session = row.session
self.selected_key = "stop-session-no"
self.message = ""
elif row.kind == "confirm-yes":
self._finish_remove(row.target or "")
elif row.kind == "delete-session-yes":
self._finish_delete_session(row.target or "", row.session or "")
elif row.kind == "stop-session-yes":
self._finish_stop_session(
row.window_id or "",
row.target or "",
row.session or "",
)
elif row.kind == "back":
self._show_main()
return None
def _finish_add(self, target: str):
try:
entry = RemoteEntry(target)
entry = RemoteEntry(
target,
self.input_remote_keybindings,
self.input_remote_transport,
)
self.registry.upsert(entry)
except (ValueError, RegistryError) as error:
self.message = str(error)
@@ -540,13 +916,161 @@ class SessionSelector:
return None
def _finish_new(self, session: str):
if not SESSION_NAME_RE.fullmatch(session):
if not is_valid_session_name(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)
return SelectorResult.remote(
target,
session,
entry.remote_keybindings,
self.input_remote_transport,
)
def _finish_rename(self, alias: str):
try:
normalized = normalize_alias(alias)
except ValueError as error:
self.message = str(error)
return None
assert self.input_key is not None
if self.rename_handler is not None:
try:
self.rename_handler(self.input_key, normalized)
except (OSError, RuntimeError, ValueError) as error:
self.message = str(error)
return None
endpoint_key = self.input_key
if normalized:
self.aliases[endpoint_key] = normalized
else:
self.aliases.pop(endpoint_key, None)
display_label = normalized
updated_windows: list[CatalogWindow] = []
for window in self.windows:
if window.key == endpoint_key:
display_label = normalized or window.default_label or window.label
window = replace(window, label=display_label)
updated_windows.append(window)
self.windows = updated_windows
self._show_manage_sessions(selected_key=f"rename:{endpoint_key}")
self.message = (
f"已保存:{display_label}"
if normalized
else f"已恢复默认名称:{display_label}"
)
return None
return SelectorResult.rename(self.input_key, normalized)
def _show_delete_session_confirmation(self, row: PickerRow) -> None:
assert row.deletable and row.target is not None and row.session is not None
self.view = "confirm-delete-session"
self.input_target = row.target
self.input_session = row.session
self.selected_key = "delete-session-no"
self.message = ""
def _finish_delete_session(self, target: str, session: str) -> None:
if self.delete_session_handler is None:
self.message = "Session 删除功能不可用"
return
self.pending_action = _PendingAction("delete", target, session)
self.view = "working-delete-session"
self.selected_key = None
self.message = "正在通过 SSH 永久删除 Session…"
self.action_future = self.action_executor.submit(
self.delete_session_handler,
target,
session,
)
def _finish_stop_session(
self,
window_id: str,
target: str,
session: str,
) -> None:
if self.stop_session_handler is None:
self.message = "Session 停止功能不可用"
return
self.pending_action = _PendingAction("stop", target, session, window_id)
self.view = "working-stop-session"
self.selected_key = None
self.message = "正在停止远端 Session…"
self.action_future = self.action_executor.submit(
self.stop_session_handler,
window_id,
target,
session,
)
def _poll_action(self) -> None:
future = self.action_future
action = self.pending_action
if future is None or action is None or not future.done():
return
self.action_future = None
self.pending_action = None
try:
outcome = future.result()
except (OSError, RuntimeError, ValueError) as error:
self._show_main()
entry = self.entries.get(action.target)
if entry is not None:
self.pool.enqueue(entry)
self.message = f"{action.target}/{action.session} 操作失败:{error}"
return
if action.kind == "delete":
result = self.results.get(action.target)
if result is not None:
self.results[action.target] = replace(
result,
sessions=tuple(
item
for item in result.sessions
if item.name != action.session
),
)
warning = (
outcome.warning
if isinstance(outcome, DeleteSessionOutcome)
else None
)
message = f"已删除历史 Session:{action.target}/{action.session}"
else:
stop_outcome = (
outcome
if isinstance(outcome, StopSessionOutcome)
else StopSessionOutcome(tuple(outcome))
)
self.windows = list(stop_outcome.windows)
result = self.results.get(action.target)
if result is not None:
self.results[action.target] = replace(
result,
sessions=tuple(
replace(item, running=False)
if item.name == action.session
else item
for item in result.sessions
),
)
warning = stop_outcome.warning
message = (
f"已关闭 Tab 并停止远端 Session:{action.target}/{action.session}"
if stop_outcome.tab_closed
else f"已停止远端 Session:{action.target}/{action.session}"
)
entry = self.entries.get(action.target)
if entry is not None:
self.pool.enqueue(entry)
self._show_main()
self.message = f"{message};{warning}" if warning else message
def _finish_remove(self, target: str) -> None:
try:
@@ -564,10 +1088,27 @@ class SessionSelector:
def _show_main(self) -> None:
self.view = "main"
self.input_target = None
self.input_session = None
self.input_window_id = None
self.input_value = ""
self.input_key = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self.selected_key = None
self.scroll = 0
def _show_manage_sessions(self, selected_key: str | None = None) -> None:
self.view = "manage-sessions"
self.input_target = None
self.input_session = None
self.input_window_id = None
self.input_value = ""
self.input_key = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self.selected_key = selected_key
self.scroll = 0
def _normalize_selection(self, rows: list[PickerRow]) -> None:
selectable = [row for row in rows if row.selectable]
if not selectable:
@@ -600,7 +1141,7 @@ class SessionSelector:
screen.refresh()
return
if self.view in {"input-add", "input-new"}:
if self.view in {"input-add", "input-new", "input-rename"}:
self._draw_input(screen, height, width)
return
try:
@@ -608,7 +1149,14 @@ class SessionSelector:
except curses.error:
pass
title = "Sessions" if self.view == "main" else "管理远端"
title = {
"main": "Sessions",
"manage-sessions": "管理 Session 显示名称",
"confirm-delete-session": "删除历史 Session",
"confirm-stop-session": "关闭并停止远端 Session",
"working-delete-session": "删除历史 Session",
"working-stop-session": "停止远端 Session",
}.get(self.view, "管理远端")
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)
@@ -638,39 +1186,97 @@ class SessionSelector:
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}"
main = pad_terminal_text(row.main, main_room)
detail = truncate_terminal_text(row.detail, detail_room)
line = f" {main} {detail}"
self._safe_add(screen, y, 0, line, attribute)
footer = "单击/Enter 打开 · 输入搜索 · Ctrl+R 刷新 · Esc 关闭"
if self.view != "main":
footer = "Enter 打开 · d 删除停止项 · Ctrl+R 刷新 · Esc 关闭"
if self.view in {"working-delete-session", "working-stop-session"}:
footer = "远端操作进行中,请稍候"
elif 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]
if self.view == "input-rename":
remaining = MAX_ALIAS_LENGTH - len(self.input_value)
self._safe_add(screen, 1, 2, "修改 Hub 显示名称", curses.A_BOLD)
self._safe_add(
screen,
2,
2,
f"底层 Session:{self.input_target}",
curses.A_DIM,
)
prompt = f"显示名称(剩余 {remaining} 字符)"
input_line = input_display_line(prompt, self.input_value, width - 3)
self._safe_add(screen, 4, 2, input_line)
self._safe_add(
screen,
5,
2,
"留空并确认可恢复默认名称;不会修改底层 Session ID",
curses.A_DIM,
)
if self.message:
self._safe_add(screen, 6, 2, self.message, curses.A_DIM)
self._safe_add(
screen,
height - 1,
1,
"Enter 保存 · Ctrl+U 清空 · Esc 返回",
curses.A_DIM,
)
try:
curses.curs_set(1)
screen.move(
4,
min(width - 2, 2 + terminal_display_width(input_line)),
)
except curses.error:
pass
screen.refresh()
return
is_add = self.view == "input-add"
title = "添加远端" if is_add else f"在 {self.input_target} 新建 Session"
title = (
"添加 Windows/SSH 远端"
if is_add and self.input_remote_transport == "ssh"
else "添加远端"
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}")
input_line = input_display_line(prompt, self.input_value, width - 3)
self._safe_add(screen, 3, 2, input_line)
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))
screen.move(
3,
min(width - 2, 2 + terminal_display_width(input_line)),
)
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]
def _safe_add( # type: ignore[no-untyped-def]
screen,
y: int,
x: int,
text: str,
attribute: int = curses.A_NORMAL,
) -> None:
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)
visible = truncate_terminal_text(text, max(0, width - x - 1))
screen.addstr(y, x, visible, attribute)
except curses.error:
pass
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from pathlib import Path
import stat
import tempfile
import unittest
from herdr_hub.aliases import (
AliasRegistry,
AliasRegistryError,
MAX_ALIAS_LENGTH,
normalize_alias,
)
class AliasRegistryTests(unittest.TestCase):
def test_set_reset_round_trip_and_permissions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config" / "aliases.json"
registry = AliasRegistry(path)
self.assertEqual(registry.load(), {})
self.assertTrue(registry.set("remote:orangepi:default", " 客厅主机 "))
self.assertFalse(registry.set("remote:orangepi:default", "客厅主机"))
self.assertEqual(
registry.load(),
{"remote:orangepi:default": "客厅主机"},
)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertTrue(registry.set("remote:orangepi:default", ""))
self.assertEqual(registry.load(), {})
def test_invalid_aliases_and_corrupt_registry_are_rejected(self) -> None:
with self.assertRaises(ValueError):
normalize_alias("x" * (MAX_ALIAS_LENGTH + 1))
with self.assertRaises(ValueError):
normalize_alias("bad\nname")
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "aliases.json"
path.write_text("not json", encoding="utf-8")
registry = AliasRegistry(path)
with self.assertRaises(AliasRegistryError):
registry.set("local:default", "工作")
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
if __name__ == "__main__":
unittest.main()
+47 -1
View File
@@ -8,6 +8,7 @@ import tempfile
import unittest
import uuid
from herdr_hub.aliases import AliasRegistry
from herdr_hub.cli import TmuxHub
@@ -29,6 +30,8 @@ class CliIntegrationTests(unittest.TestCase):
"orangepi",
"--remote-keybindings",
"server",
"--transport",
"ssh",
],
cwd=repository_root,
env=environment,
@@ -45,7 +48,7 @@ class CliIntegrationTests(unittest.TestCase):
text=True,
check=False,
)
self.assertEqual(listing.stdout.strip(), "orangepi\tserver")
self.assertEqual(listing.stdout.strip(), "orangepi\tserver\tssh")
remove = subprocess.run(
[str(launcher), "remote", "remove", "orangepi"],
cwd=repository_root,
@@ -87,7 +90,9 @@ class CliIntegrationTests(unittest.TestCase):
"HERDR_HUB_SESSION": "hub",
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
"HERDR_HUB_PROC_ROOT": str(proc_root),
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
}
AliasRegistry(root / "aliases.json").set("local:default", "主工作")
refresh = subprocess.run(
[str(launcher), "refresh"],
@@ -118,7 +123,11 @@ class CliIntegrationTests(unittest.TestCase):
[window.key for window in hub.managed_windows()],
["local:default"],
)
self.assertEqual(hub.managed_windows()[0].name, "主工作")
# Operational recovery must remain possible even if optional
# display-name configuration is later corrupted.
(root / "aliases.json").write_text("not json", encoding="utf-8")
stop = subprocess.run(
[str(launcher), "stop"],
cwd=repository_root,
@@ -131,6 +140,43 @@ class CliIntegrationTests(unittest.TestCase):
self.assertIn("Herdr servers were left running", stop.stdout)
self.assertFalse(hub.exists())
def test_empty_refresh_does_not_leave_a_bootstrap_hub(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
launcher = repository_root / "bin" / "herdr-hub"
socket_name = f"herdr-hub-empty-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
proc_root = root / "proc"
proc_root.mkdir()
environment = {
**os.environ,
"HERDR_HUB_HERDR_BIN": "/bin/false",
"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),
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
"HERDR_HUB_REMOTES_FILE": str(root / "remotes.json"),
}
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)
hub = TmuxHub(
tmux_bin=shutil.which("tmux") or "tmux",
socket_name=socket_name,
session_name="hub",
config_file="/dev/null",
)
self.addCleanup(hub.stop)
self.assertFalse(hub.exists())
if __name__ == "__main__":
unittest.main()
+31 -1
View File
@@ -39,12 +39,37 @@ class EndpointTests(unittest.TestCase):
"work",
],
)
self.assertEqual(
Endpoint.remote(
"ssh://me@windows:2222",
"work",
remote_transport="ssh",
).command("/bin/herdr", "/bin/ssh"),
[
"/bin/ssh",
"-tt",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
"-p",
"2222",
"-l",
"me",
"windows",
"herdr --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])
self.assertEqual(
Endpoint.remote("orangepi").key,
Endpoint.remote("orangepi", remote_transport="ssh").key,
)
def test_short_labels_put_the_host_first_and_omit_default(self) -> None:
self.assertEqual(Endpoint.local().short_label, "local")
@@ -67,7 +92,12 @@ class EndpointTests(unittest.TestCase):
)
def test_endpoint_metadata_and_legacy_command_round_trip(self) -> None:
endpoint = Endpoint.remote("ssh://me@host:2222", "work", "server")
endpoint = Endpoint.remote(
"ssh://me@host:2222",
"work",
"server",
"ssh",
)
self.assertEqual(deserialize_endpoint(serialize_endpoint(endpoint)), endpoint)
self.assertEqual(
deserialize_endpoint(
+181 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from pathlib import Path
import stat
import tempfile
@@ -7,14 +8,21 @@ import time
import unittest
from herdr_hub.remotes import (
REMOTE_DIRECT_COMMAND,
REMOTE_LIST_SCRIPT,
RegistryError,
RemoteEntry,
RemoteQueryPool,
RemoteRegistry,
RemoteTargetError,
delete_remote_session,
decode_remote_output,
parse_remote_session_result,
remote_delete_session_command,
remote_session_command,
remote_stop_session_command,
ssh_destination_arguments,
stop_remote_session,
)
@@ -26,15 +34,31 @@ class RemoteRegistryTests(unittest.TestCase):
self.assertEqual(registry.load(), [])
self.assertTrue(registry.upsert(RemoteEntry("orangepi")))
self.assertFalse(registry.upsert(RemoteEntry("orangepi")))
self.assertTrue(registry.upsert(RemoteEntry("prod", "server")))
self.assertTrue(registry.upsert(RemoteEntry("prod", "server", "ssh")))
self.assertEqual(
registry.load(),
[RemoteEntry("orangepi"), RemoteEntry("prod", "server")],
[RemoteEntry("orangepi"), RemoteEntry("prod", "server", "ssh")],
)
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")])
self.assertEqual(
registry.load(),
[RemoteEntry("prod", "server", "ssh")],
)
def test_discovery_does_not_overwrite_preferences_and_ssh_is_sticky(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("host", "server"))
registry.remember_many([RemoteEntry("host", "local")])
self.assertEqual(registry.load(), [RemoteEntry("host", "server")])
registry.remember_many([RemoteEntry("host", "local", "ssh")])
self.assertEqual(
registry.load(),
[RemoteEntry("host", "server", "ssh")],
)
def test_corrupt_registry_is_not_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -45,6 +69,25 @@ class RemoteRegistryTests(unittest.TestCase):
registry.upsert(RemoteEntry("orangepi"))
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
def test_legacy_registry_defaults_to_native_transport(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "remotes.json"
path.write_text(
json.dumps(
{
"version": 1,
"remotes": [
{"target": "legacy", "remote_keybindings": "server"}
],
}
),
encoding="utf-8",
)
self.assertEqual(
RemoteRegistry(path).load(),
[RemoteEntry("legacy", "server", "native")],
)
class RemoteSshTests(unittest.TestCase):
def test_alias_and_url_targets_become_safe_ssh_arguments(self) -> None:
@@ -56,12 +99,93 @@ class RemoteSshTests(unittest.TestCase):
command = remote_session_command("/usr/bin/ssh", RemoteEntry("host name"))
self.assertEqual(command[0], "/usr/bin/ssh")
self.assertIn("host name", command)
self.assertEqual(command[-1], REMOTE_DIRECT_COMMAND)
fallback = remote_session_command(
"/usr/bin/ssh",
RemoteEntry("orangepi"),
posix_fallback=True,
)
self.assertEqual(fallback[-1], REMOTE_LIST_SCRIPT)
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_delete_stopped_remote_session_uses_safe_command_and_posix_fallback(self) -> None:
with tempfile.TemporaryDirectory() as directory:
fake_ssh = Path(directory) / "ssh"
fake_ssh.write_text(
"#!/bin/sh\n"
"last=''\n"
"for argument do last=$argument; done\n"
"case \"$last\" in\n"
" *\"if [ -x\"*)\n"
" printf '%s\\n' '{\"deleted\":true}'\n"
" exit 0\n"
" ;;\n"
"esac\n"
"exit 127\n",
encoding="utf-8",
)
fake_ssh.chmod(0o755)
delete_remote_session(
str(fake_ssh),
RemoteEntry("orangepi"),
"old-session",
timeout=1,
)
command = remote_delete_session_command(
"/usr/bin/ssh",
RemoteEntry("orangepi"),
"old-session",
)
self.assertEqual(
command[-1],
"herdr session delete --json -- old-session",
)
with self.assertRaises(ValueError):
remote_delete_session_command(
"/usr/bin/ssh",
RemoteEntry("orangepi"),
"bad session",
)
def test_stop_running_remote_session_uses_safe_command_and_posix_fallback(self) -> None:
with tempfile.TemporaryDirectory() as directory:
fake_ssh = Path(directory) / "ssh"
fake_ssh.write_text(
"#!/bin/sh\n"
"last=''\n"
"for argument do last=$argument; done\n"
"case \"$last\" in\n"
" *\"if [ -x\"*) exit 0 ;;\n"
"esac\n"
"exit 127\n",
encoding="utf-8",
)
fake_ssh.chmod(0o755)
stop_remote_session(
str(fake_ssh),
RemoteEntry("orangepi"),
"work",
timeout=1,
)
command = remote_stop_session_command(
"/usr/bin/ssh",
RemoteEntry("orangepi"),
"work",
)
self.assertEqual(command[-1], "herdr session stop -- work")
with self.assertRaises(ValueError):
remote_stop_session_command(
"/usr/bin/ssh",
RemoteEntry("orangepi"),
"bad session",
)
def test_remote_session_json_keeps_running_and_stopped(self) -> None:
result = parse_remote_session_result(
RemoteEntry("orangepi"),
@@ -77,6 +201,15 @@ class RemoteSshTests(unittest.TestCase):
[("default", True), ("old", False)],
)
windows = parse_remote_session_result(
RemoteEntry("windows"),
0,
'{"sessions":[{"name":"default","running":true,'
'"session_dir":"C:\\\\Users\\\\me\\\\AppData\\\\Roaming\\\\herdr"}]}',
"",
)
self.assertEqual(windows.platform, "windows")
def test_query_pool_reports_results_and_spawn_failures(self) -> None:
with tempfile.TemporaryDirectory() as directory:
fake_ssh = Path(directory) / "ssh"
@@ -103,6 +236,51 @@ class RemoteSshTests(unittest.TestCase):
self.assertEqual(len(failure), 1)
self.assertIn("cannot start", failure[0].error or "")
def test_query_pool_falls_back_for_posix_path_and_decodes_windows_errors(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
fallback_ssh = root / "fallback-ssh"
fallback_ssh.write_text(
"#!/bin/sh\n"
"last=''\n"
"for argument do last=$argument; done\n"
"case \"$last\" in\n"
" *\"if [ -x\"*)\n"
" printf '%s\\n' '{\"sessions\":[{\"name\":\"work\",\"running\":true}]}'\n"
" exit 0\n"
" ;;\n"
"esac\n"
"exit 127\n",
encoding="utf-8",
)
fallback_ssh.chmod(0o755)
pool = RemoteQueryPool(str(fallback_ssh), query_timeout=1)
self.addCleanup(pool.close)
pool.enqueue(RemoteEntry("linux-host"))
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")
windows_ssh = root / "windows-ssh"
windows_ssh.write_bytes(
b"#!/bin/sh\nprintf '\\264\\355\\316\\363' >&2\nexit 1\n"
)
windows_ssh.chmod(0o755)
windows_pool = RemoteQueryPool(str(windows_ssh), query_timeout=1)
self.addCleanup(windows_pool.close)
windows_pool.enqueue(RemoteEntry("windows-host"))
windows_results = []
deadline = time.monotonic() + 2
while not windows_results and time.monotonic() < deadline:
windows_results = windows_pool.poll()
time.sleep(0.01)
self.assertIn("错误", windows_results[0].error or "")
self.assertEqual(decode_remote_output("错误".encode("gbk")), "错误")
def test_query_pool_times_out_without_blocking_other_results(self) -> None:
with tempfile.TemporaryDirectory() as directory:
slow_ssh = Path(directory) / "ssh"
+379
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import curses
from dataclasses import replace
import os
from pathlib import Path
import pty
@@ -8,19 +10,42 @@ import signal
import tempfile
import time
import unittest
from unittest.mock import patch
from herdr_hub.remotes import RemoteEntry, RemoteQueryResult, RemoteRegistry, RemoteSession
from herdr_hub.selector import (
CatalogWindow,
DeleteSessionOutcome,
PickerRow,
SessionSelector,
SelectorResult,
build_main_rows,
filter_rows,
input_display_line,
pad_terminal_text,
terminal_display_width,
truncate_terminal_text,
)
def wait_for_action(selector: SessionSelector) -> None:
deadline = time.monotonic() + 2
while selector.action_future is not None and time.monotonic() < deadline:
selector._poll_action() # type: ignore[attr-defined]
time.sleep(0.01)
if selector.action_future is not None:
raise AssertionError("selector action did not finish")
class SelectorCatalogTests(unittest.TestCase):
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
self.assertEqual(terminal_display_width("Rename: work"), 12)
self.assertEqual(terminal_display_width("显示名称: 工作"), 14)
self.assertEqual(terminal_display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(truncate_terminal_text("主机abc", 5), "主机a")
self.assertEqual(terminal_display_width(pad_terminal_text("主机", 6)), 6)
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
windows = [
CatalogWindow(
@@ -31,6 +56,7 @@ class SelectorCatalogTests(unittest.TestCase):
True,
kind="local",
session="default",
default_label="L default",
),
CatalogWindow(
"@2",
@@ -41,6 +67,7 @@ class SelectorCatalogTests(unittest.TestCase):
kind="remote",
target="orangepi",
session="work",
default_label="R orangepi/work",
),
]
result = RemoteQueryResult(
@@ -63,9 +90,13 @@ class SelectorCatalogTests(unittest.TestCase):
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.assertTrue(by_key["remote:orangepi:old"].deletable)
self.assertIn("d 删除", by_key["remote:orangepi:old"].detail)
self.assertFalse(by_key["remote:orangepi:default"].deletable)
self.assertIn("new:orangepi", by_key)
self.assertEqual(by_key["local:draft"].main, "○ draft")
self.assertEqual(by_key["close-active"].window_id, "@1")
self.assertIn("manage-sessions", by_key)
def test_loading_error_and_filter_rows(self) -> None:
entry = RemoteEntry("prod")
@@ -81,8 +112,288 @@ class SelectorCatalogTests(unittest.TestCase):
matches = filter_rows(failed, "offline")
self.assertEqual([row.kind for row in matches], ["retry"])
def test_active_remote_session_has_close_and_stop_action(self) -> None:
local = CatalogWindow(
"@1",
"local:default",
"L default",
False,
False,
kind="local",
session="default",
)
remote = CatalogWindow(
"@2",
"remote:orangepi:work",
"R orangepi/work",
False,
True,
kind="remote",
target="orangepi",
session="work",
)
rows = build_main_rows(
[local, remote],
[RemoteEntry("orangepi")],
{},
{"orangepi"},
)
stop = next(row for row in rows if row.kind == "stop-session")
self.assertTrue(stop.selectable)
self.assertEqual(stop.window_id, "@2")
self.assertIn("任务会中断", stop.detail)
last_tab_rows = build_main_rows(
[remote],
[RemoteEntry("orangepi")],
{},
{"orangepi"},
)
last_stop = next(
row for row in last_tab_rows if row.kind == "stop-session"
)
self.assertTrue(last_stop.selectable)
self.assertIn("Tab 将保留", last_stop.detail)
def test_windows_remote_migrates_to_ssh_transport(self) -> None:
window = CatalogWindow(
"@4",
"remote:windows:default",
"R windows/default",
True,
False,
kind="remote",
target="windows",
session="default",
remote_transport="native",
)
result = RemoteQueryResult(
RemoteEntry("windows"),
sessions=(RemoteSession("default", True),),
platform="windows",
)
rows = build_main_rows(
[window],
[RemoteEntry("windows")],
{"windows": result},
set(),
)
remote = next(row for row in rows if row.kind == "remote")
self.assertEqual(remote.remote_transport, "ssh")
self.assertIn("切换为 SSH 模式", remote.detail)
def test_existing_ssh_window_is_never_downgraded_while_query_is_unknown(self) -> None:
entry = RemoteEntry("windows")
window = CatalogWindow(
"@4",
"remote:windows:default",
"windows",
False,
True,
kind="remote",
target="windows",
session="default",
remote_transport="ssh",
)
for results, loading in [
({}, {"windows"}),
({"windows": RemoteQueryResult(entry, error="offline")}, set()),
({"windows": RemoteQueryResult(entry)}, set()),
]:
rows = build_main_rows([window], [entry], results, loading)
row = next(item for item in rows if item.key == "window:@4")
self.assertEqual(row.kind, "window")
def test_explicit_ssh_entry_supports_an_empty_windows_host(self) -> None:
entry = RemoteEntry("windows", remote_transport="ssh")
rows = build_main_rows(
[],
[entry],
{"windows": RemoteQueryResult(entry)},
set(),
)
new_row = next(item for item in rows if item.kind == "new")
self.assertEqual(new_row.remote_transport, "ssh")
def test_windows_new_session_keeps_the_row_transport(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
entry = RemoteEntry("windows")
registry.upsert(entry)
selector = SessionSelector([], registry, "/missing/ssh")
self.addCleanup(selector.pool.close)
selector.results["windows"] = RemoteQueryResult(
entry,
sessions=(RemoteSession("default", True),),
platform="windows",
)
new_row = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "new"
)
selector._activate(new_row) # type: ignore[attr-defined]
result = selector._finish_new("work") # type: ignore[attr-defined]
self.assertEqual(
result,
SelectorResult.remote("windows", "work", "local", "ssh"),
)
class SelectorActionTests(unittest.TestCase):
def test_remote_session_can_be_stopped_and_closed_without_closing_popup(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi"))
local = CatalogWindow(
"@1",
"local:default",
"L default",
False,
False,
kind="local",
session="default",
)
remote = CatalogWindow(
"@2",
"remote:orangepi:work",
"R orangepi/work",
False,
True,
kind="remote",
target="orangepi",
session="work",
)
stopped: list[tuple[str, str, str]] = []
def stop_handler(
window_id: str,
target: str,
session: str,
) -> list[CatalogWindow]:
stopped.append((window_id, target, session))
return [replace(local, active=True)]
selector = SessionSelector(
[local, remote],
registry,
str(Path(directory) / "missing-ssh"),
stop_session_handler=stop_handler,
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(RemoteSession("work", True),),
)
stop_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "stop-session"
)
selector._activate(stop_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-stop-session")
confirm = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "stop-session-yes"
)
selector._activate(confirm) # type: ignore[attr-defined]
self.assertEqual(selector.view, "working-stop-session")
wait_for_action(selector)
self.assertEqual(stopped, [("@2", "orangepi", "work")])
self.assertEqual(selector.view, "main")
self.assertEqual([window.window_id for window in selector.windows], ["@1"])
self.assertFalse(selector.results["orangepi"].sessions[0].running)
self.assertIn("已关闭 Tab 并停止", selector.message)
def test_stopped_remote_session_can_be_deleted_without_closing_popup(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi"))
deleted: list[tuple[str, str]] = []
selector = SessionSelector(
[],
registry,
str(Path(directory) / "missing-ssh"),
delete_session_handler=lambda target, session: deleted.append(
(target, session)
),
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(
RemoteSession("default", True),
RemoteSession("old", False),
),
)
rows = selector._rows() # type: ignore[attr-defined]
running = next(
row for row in rows if row.key == "remote:orangepi:default"
)
selector.selected_key = running.key
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "main")
self.assertEqual(deleted, [])
selector.query = ""
old = next(row for row in rows if row.key == "remote:orangepi:old")
selector.selected_key = old.key
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-delete-session")
confirm = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "delete-session-yes"
)
selector._drawn_rows[6] = confirm # type: ignore[attr-defined]
with patch(
"herdr_hub.selector.curses.getmouse",
return_value=(0, 0, 6, 0, curses.BUTTON1_RELEASED),
):
selector._handle_mouse( # type: ignore[attr-defined]
selector._rows() # type: ignore[attr-defined]
)
self.assertEqual(selector.view, "working-delete-session")
wait_for_action(selector)
self.assertEqual(deleted, [("orangepi", "old")])
self.assertEqual(selector.view, "main")
self.assertIn("已删除历史 Session", selector.message)
self.assertNotIn(
"old",
[item.name for item in selector.results["orangepi"].sessions],
)
def test_delete_partial_success_is_reported_as_warning_not_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("host"))
selector = SessionSelector(
[],
registry,
"/missing/ssh",
delete_session_handler=lambda _target, _session: DeleteSessionOutcome(
"显示名称清理失败"
),
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["host"] = RemoteQueryResult(
RemoteEntry("host"),
sessions=(RemoteSession("old", False),),
)
selector._finish_delete_session("host", "old") # type: ignore[attr-defined]
wait_for_action(selector)
self.assertEqual(selector.view, "main")
self.assertIn("已删除历史 Session", selector.message)
self.assertIn("显示名称清理失败", selector.message)
def test_dead_window_reconnect_action_and_new_session_action(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
@@ -108,6 +419,74 @@ class SelectorActionTests(unittest.TestCase):
)
self.assertEqual(local_result, SelectorResult.local("draft"))
selector = SessionSelector(
[
CatalogWindow(
"@3",
"local:default",
"日常工作",
False,
True,
kind="local",
session="default",
default_label="L default",
)
],
registry,
str(Path(directory) / "missing-ssh"),
aliases={"local:default": "日常工作"},
)
self.addCleanup(selector.pool.close)
selector._activate( # type: ignore[attr-defined]
next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "manage-sessions"
)
)
rename_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "rename"
)
selector._activate(rename_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "input-rename")
self.assertEqual(selector.input_value, "日常工作")
self.assertEqual(
selector._finish_rename(" 新名称 "), # type: ignore[attr-defined]
SelectorResult.rename("local:default", "新名称"),
)
self.assertEqual(
selector._finish_rename(""), # type: ignore[attr-defined]
SelectorResult.rename("local:default", ""),
)
renamed: list[tuple[str, str]] = []
live_selector = SessionSelector(
selector.windows,
registry,
str(Path(directory) / "missing-ssh"),
aliases={"local:default": "日常工作"},
rename_handler=lambda key, alias: renamed.append((key, alias)),
)
self.addCleanup(live_selector.pool.close)
live_selector._show_manage_sessions() # type: ignore[attr-defined]
live_selector._activate( # type: ignore[attr-defined]
next(
row
for row in live_selector._rows() # type: ignore[attr-defined]
if row.kind == "rename"
)
)
self.assertIsNone(
live_selector._finish_rename("连续改名") # type: ignore[attr-defined]
)
self.assertEqual(renamed, [("local:default", "连续改名")])
self.assertEqual(live_selector.view, "manage-sessions")
self.assertEqual(live_selector.selected_key, "rename:local:default")
self.assertEqual(live_selector.aliases["local:default"], "连续改名")
self.assertIn("已保存", live_selector.message)
selector.view = "input-new"
selector.input_target = "orangepi"
new_result = selector._finish_new("work") # type: ignore[attr-defined]
+100 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import shutil
@@ -33,12 +34,16 @@ class TmuxIntegrationTests(unittest.TestCase):
encoding="utf-8",
)
self.fake_herdr.chmod(0o755)
self.fake_ssh = root / "ssh"
self.fake_ssh.write_text("#!/bin/sh\nexec sleep 30\n", encoding="utf-8")
self.fake_ssh.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",
ssh_bin=str(self.fake_ssh),
socket_name=self.socket_name,
session_name="hub",
config_file="/dev/null",
@@ -86,6 +91,9 @@ class TmuxIntegrationTests(unittest.TestCase):
status_right = self.hub.run(
["show-options", "-gv", "status-right"]
).stdout.strip()
expanded_status_left = self.hub.run(
["display-message", "-p", "#{E:status-left}"]
).stdout.strip()
dead_format = self.hub.run(
["show-options", "-wgv", "remain-on-exit-format"]
).stdout.strip()
@@ -99,14 +107,21 @@ class TmuxIntegrationTests(unittest.TestCase):
["list-keys", "-T", "root", "MouseDown1StatusLeft"]
).stdout.strip()
close_binding = self.hub.run(
["list-keys", "-T", "prefix", "&"]
["list-keys", "-T", "prefix", "q"]
).stdout.strip()
legacy_close_binding = self.hub.run(
["list-keys", "-T", "prefix", "&"], check=False
)
uppercase_close_binding = self.hub.run(
["list-keys", "-T", "prefix", "Q"], check=False
)
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.assertTrue(expanded_status_left.startswith("#[bold,fg=colour81]"))
self.assertIn("C-a R", status_right)
self.assertIn("连接已断开", dead_format)
self.assertIn("Ctrl+A R", dead_format)
@@ -116,8 +131,11 @@ class TmuxIntegrationTests(unittest.TestCase):
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("-EE", status_left_binding)
self.assertIn("confirm-before", close_binding)
self.assertIn(" close --window ", close_binding)
self.assertNotEqual(legacy_close_binding.returncode, 0)
self.assertNotEqual(uppercase_close_binding.returncode, 0)
local_short_label = self.hub.run(
[
@@ -201,6 +219,87 @@ class TmuxIntegrationTests(unittest.TestCase):
self.hub.reconnect(window.window_id)
self.assertIsNotNone(self.hub.window_for_key("local:dead"))
def test_alias_is_applied_immediately_and_can_be_reset(self) -> None:
self.hub.aliases["remote:orangepi:work"] = "客厅开发"
self.hub.ensure_server()
window = self.hub.create_window(
Endpoint.remote("orangepi", "work"),
str(self.fake_herdr),
)
self.hub.remove_bootstrap()
current = self.hub.window_for_key(window.key)
assert current is not None
self.assertEqual(current.name, "客厅开发")
short_label = self.hub.run(
[
"show-options",
"-wv",
"-t",
window.window_id,
WINDOW_SHORT_LABEL_OPTION,
]
).stdout.strip()
self.assertEqual(short_label, "客厅开发")
renamed = self.hub.apply_alias(window.key, "临时名称")
assert renamed is not None
self.assertEqual(renamed.name, "临时名称")
restored = self.hub.apply_alias(window.key, "")
assert restored is not None
self.assertEqual(restored.name, "R orangepi/work")
def test_failed_native_window_can_migrate_to_windows_ssh_transport(self) -> None:
self.hub.ensure_server()
native = self.hub.create_window(
Endpoint.remote("windows"),
str(self.fake_herdr),
)
self.hub.remove_bootstrap()
migrated = self.hub.activate_endpoint(
Endpoint.remote("windows", remote_transport="ssh"),
str(self.fake_herdr),
)
self.assertEqual(migrated.window_id, native.window_id)
assert migrated.endpoint is not None
self.assertEqual(migrated.endpoint.remote_transport, "ssh")
command = self.hub.run(
[
"show-options",
"-wv",
"-t",
native.window_id,
"@herdr_hub_command",
]
).stdout.strip()
self.assertIn(str(self.fake_ssh), command)
self.assertIn("-tt", command)
def test_concurrent_open_of_the_same_endpoint_creates_one_window(self) -> None:
self.hub.ensure_server()
second_hub = TmuxHub(
tmux_bin=shutil.which("tmux") or "tmux",
ssh_bin=str(self.fake_ssh),
socket_name=self.socket_name,
session_name="hub",
config_file="/dev/null",
script_path="/dev/null",
)
endpoint = Endpoint.local("concurrent")
with ThreadPoolExecutor(max_workers=2) as executor:
windows = list(
executor.map(
lambda hub: hub.activate_endpoint(endpoint, str(self.fake_herdr)),
[self.hub, second_hub],
)
)
self.assertEqual(windows[0].window_id, windows[1].window_id)
self.assertEqual(
[window.key for window in self.hub.managed_windows()],
["local:concurrent"],
)
if __name__ == "__main__":
unittest.main()