diff --git a/README.md b/README.md index 4f39f42..46e639c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/herdr_hub/aliases.py b/herdr_hub/aliases.py new file mode 100644 index 0000000..b9a4e21 --- /dev/null +++ b/herdr_hub/aliases.py @@ -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") diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index 00b2775..c3dfb2e 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -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: diff --git a/herdr_hub/domain.py b/herdr_hub/domain.py new file mode 100644 index 0000000..b6562d7 --- /dev/null +++ b/herdr_hub/domain.py @@ -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 '-'" + ) diff --git a/herdr_hub/remotes.py b/herdr_hub/remotes.py index 5eea5b8..0bd5b81 100644 --- a/herdr_hub/remotes.py +++ b/herdr_hub/remotes.py @@ -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 diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index 1aefdf3..b90f6ed 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -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 diff --git a/tests/test_aliases.py b/tests/test_aliases.py new file mode 100644 index 0000000..11182ce --- /dev/null +++ b/tests/test_aliases.py @@ -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() diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index dc31deb..6df0cb6 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -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() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index a9998f4..917fe36 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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( diff --git a/tests/test_remotes.py b/tests/test_remotes.py index 928cbf9..c7936b8 100644 --- a/tests/test_remotes.py +++ b/tests/test_remotes.py @@ -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" diff --git a/tests/test_selector.py b/tests/test_selector.py index 37e5e7a..0257f4d 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -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] diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index 444ba2d..46cc592 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -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()