Improve remote session management and Windows support
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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")
|
||||
+376
-48
@@ -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,13 +723,21 @@ 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._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",
|
||||
@@ -625,10 +745,21 @@ class TmuxHub:
|
||||
"-t",
|
||||
window.window_id,
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
label,
|
||||
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")
|
||||
try:
|
||||
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),
|
||||
(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,7 +1146,6 @@ 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()
|
||||
detail = "; ".join(report.warnings)
|
||||
suffix = f" ({detail})" if detail else ""
|
||||
@@ -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:
|
||||
|
||||
@@ -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 '-'"
|
||||
)
|
||||
+288
-12
@@ -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()
|
||||
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,
|
||||
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())
|
||||
return None
|
||||
return _RemoteJob(entry, process, time.monotonic(), posix_fallback)
|
||||
|
||||
@staticmethod
|
||||
def _stop_process(process: subprocess.Popen[str]) -> None:
|
||||
def _stop_process(process: subprocess.Popen[bytes]) -> None:
|
||||
if process.poll() is not None:
|
||||
process.communicate()
|
||||
return
|
||||
|
||||
+643
-37
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -8,6 +8,7 @@ import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from herdr_hub.aliases import AliasRegistry
|
||||
from herdr_hub.cli import TmuxHub
|
||||
|
||||
|
||||
@@ -29,6 +30,8 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
"orangepi",
|
||||
"--remote-keybindings",
|
||||
"server",
|
||||
"--transport",
|
||||
"ssh",
|
||||
],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
@@ -45,7 +48,7 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(listing.stdout.strip(), "orangepi\tserver")
|
||||
self.assertEqual(listing.stdout.strip(), "orangepi\tserver\tssh")
|
||||
remove = subprocess.run(
|
||||
[str(launcher), "remote", "remove", "orangepi"],
|
||||
cwd=repository_root,
|
||||
@@ -87,7 +90,9 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
"HERDR_HUB_SESSION": "hub",
|
||||
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
|
||||
"HERDR_HUB_PROC_ROOT": str(proc_root),
|
||||
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
|
||||
}
|
||||
AliasRegistry(root / "aliases.json").set("local:default", "主工作")
|
||||
|
||||
refresh = subprocess.run(
|
||||
[str(launcher), "refresh"],
|
||||
@@ -118,7 +123,11 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
[window.key for window in hub.managed_windows()],
|
||||
["local:default"],
|
||||
)
|
||||
self.assertEqual(hub.managed_windows()[0].name, "主工作")
|
||||
|
||||
# Operational recovery must remain possible even if optional
|
||||
# display-name configuration is later corrupted.
|
||||
(root / "aliases.json").write_text("not json", encoding="utf-8")
|
||||
stop = subprocess.run(
|
||||
[str(launcher), "stop"],
|
||||
cwd=repository_root,
|
||||
@@ -131,6 +140,43 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("Herdr servers were left running", stop.stdout)
|
||||
self.assertFalse(hub.exists())
|
||||
|
||||
def test_empty_refresh_does_not_leave_a_bootstrap_hub(self) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
launcher = repository_root / "bin" / "herdr-hub"
|
||||
socket_name = f"herdr-hub-empty-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
proc_root = root / "proc"
|
||||
proc_root.mkdir()
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HERDR_HUB_HERDR_BIN": "/bin/false",
|
||||
"HERDR_HUB_TMUX_BIN": shutil.which("tmux") or "tmux",
|
||||
"HERDR_HUB_SOCKET": socket_name,
|
||||
"HERDR_HUB_SESSION": "hub",
|
||||
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
|
||||
"HERDR_HUB_PROC_ROOT": str(proc_root),
|
||||
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
|
||||
"HERDR_HUB_REMOTES_FILE": str(root / "remotes.json"),
|
||||
}
|
||||
refresh = subprocess.run(
|
||||
[str(launcher), "refresh"],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(refresh.returncode, 0, refresh.stderr)
|
||||
hub = TmuxHub(
|
||||
tmux_bin=shutil.which("tmux") or "tmux",
|
||||
socket_name=socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
)
|
||||
self.addCleanup(hub.stop)
|
||||
self.assertFalse(hub.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+31
-1
@@ -39,12 +39,37 @@ class EndpointTests(unittest.TestCase):
|
||||
"work",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
Endpoint.remote(
|
||||
"ssh://me@windows:2222",
|
||||
"work",
|
||||
remote_transport="ssh",
|
||||
).command("/bin/herdr", "/bin/ssh"),
|
||||
[
|
||||
"/bin/ssh",
|
||||
"-tt",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-p",
|
||||
"2222",
|
||||
"-l",
|
||||
"me",
|
||||
"windows",
|
||||
"herdr --session work",
|
||||
],
|
||||
)
|
||||
|
||||
def test_identity_ignores_remote_keybinding_mode(self) -> None:
|
||||
local = Endpoint.remote("orangepi", remote_keybindings="local")
|
||||
server = Endpoint.remote("orangepi", remote_keybindings="server")
|
||||
self.assertEqual(local.key, server.key)
|
||||
self.assertEqual(dedupe_endpoints([local, server]), [local])
|
||||
self.assertEqual(
|
||||
Endpoint.remote("orangepi").key,
|
||||
Endpoint.remote("orangepi", remote_transport="ssh").key,
|
||||
)
|
||||
|
||||
def test_short_labels_put_the_host_first_and_omit_default(self) -> None:
|
||||
self.assertEqual(Endpoint.local().short_label, "local")
|
||||
@@ -67,7 +92,12 @@ class EndpointTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_endpoint_metadata_and_legacy_command_round_trip(self) -> None:
|
||||
endpoint = Endpoint.remote("ssh://me@host:2222", "work", "server")
|
||||
endpoint = Endpoint.remote(
|
||||
"ssh://me@host:2222",
|
||||
"work",
|
||||
"server",
|
||||
"ssh",
|
||||
)
|
||||
self.assertEqual(deserialize_endpoint(serialize_endpoint(endpoint)), endpoint)
|
||||
self.assertEqual(
|
||||
deserialize_endpoint(
|
||||
|
||||
+181
-3
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user