From 36bee161cb31848c7c69e287ab62f19198d08278 Mon Sep 17 00:00:00 2001 From: Donald Date: Thu, 3 Sep 2026 14:42:25 +0000 Subject: [PATCH] Simplify session manager interaction model --- README.md | 52 ++- herdr_hub/cli.py | 5 + herdr_hub/selector.py | 1012 +++++++++++++++++++++++++--------------- tests/test_selector.py | 430 +++++++++++++---- 4 files changed, 1010 insertions(+), 489 deletions(-) diff --git a/README.md b/README.md index fb6b7e6..269a254 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外 | 扫描新增 sessions | `Ctrl+A r` 或 `herdr-hub refresh` | | 强制重连当前 window | `Ctrl+A R` | | 关闭/隐藏当前 Tab | `Ctrl+A q`,确认 `y`;也可在 Sessions 弹窗中选择 | -| 关闭并停止当前远端 Session | Sessions 弹窗 → `关闭并停止当前 Session`;最后一个 Tab 会保留为断线状态 | -| 修改 Tab 显示名称 | Sessions 弹窗 → `管理 Session 显示名称…` | +| 关闭并停止远端 Session | Sessions 弹窗 → 选中 Session → `Space` / `[⋯]` → `关闭并停止 Session` | +| 修改 Tab 显示名称 | Sessions 弹窗 → 选中 Session → `Space` / `[⋯]` → `修改显示名称` | | detach Hub | `Ctrl+A d` | | 重新进入 Hub | `herdr-hub` | | 停止 Hub clients | `herdr-hub stop` | @@ -110,44 +110,46 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外 弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的 window,同时在后台并行查询所有已登记远端;每台机器的结果就绪后原地更新。 -状态与选择行为: +主页面只显示 Session 和两个全局入口。大多数主机只有 `default` Session,因此默认只显示 +主机名;有命名 Session 时显示为 `主机 / Session`。本机同样显示为“本机”或 +“本机 / Session”。状态与主操作如下: | 状态 | 选择后的行为 | | --- | --- | | `▶` 当前 / `●` 已打开 | 跳转到已有 window | | `×` 已断线 | 自动重连并跳转 | | `○` 远端运行 | 新建 attach window 并跳转 | -| `■` 远端已停止 | Enter/单击启动;命名 Session 可按 `d` 或 `D` 删除 | +| `■` 远端已停止 | Enter/单击启动并打开 | | `↻` Windows 旧窗口 | 原地切换为 SSH TTY 模式并重连 | -弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr -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 返回/关闭。 +Enter 或单击一行始终执行它的主操作。管理操作统一放在上下文菜单中:键盘选中一行后按 +`Space`,或在手机端点击行尾 `[⋯]`;右上角 `[操作]` 也会打开当前行的菜单。菜单会按 +Session 当前状态提供修改显示名称、关闭 Hub Tab、关闭并停止 Session、永久删除已停止的 +命名 Session、在此远端新建 Session、重试连接和移除远端记录等可用操作。Esc 逐层返回; +危险操作会二次确认并默认选中“取消”。关闭 Hub Tab 只结束该 Tab 中的 client,底层 +Session 和工作现场保持运行;“关闭并停止”则会中断远端任务。若它是 Hub 中最后一个 Tab, +该 Tab 会保留为断线状态,避免销毁承载弹窗的最后一个 window。 -远端查询失败时,该远端行支持 Enter 重试,也支持按 `d` 或 `D` 二次确认后从 Hub 的 -历史列表移除。这只删除本机保存的远端登记,不会连接或修改不可达主机上的数据;需要时 -可再次通过“添加远端”登记。 +主页面只有一个“新建 Session”入口,选择远端后再输入 Session 名;“添加远端”会先让你 +选择“自动检测(Linux/macOS)”或“Windows/SSH”。修改显示名称支持最多 48 个字符,留空 +保存可恢复默认名称,且不会修改底层 Session ID。移除远端记录只删除本机记忆,不关闭已有 +Tab,也不停止远端 Herdr server。 -选中已停止的命名远端 Session 后,按 `d` 或 `D` 会进入二次确认;无论是否仍有关联 -Hub Tab 都可以删除。可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台 -通过 SSH 执行 `herdr session delete`,永久删除该 Session 的工作现场和历史数据;若仍有 -关联 Tab,也会一并关闭,然后留在弹窗中继续管理。运行中的 Session 不允许删除;Herdr -自身不支持删除永久保留的 `default` Session。若关联 Tab 是 Hub 的最后一个 Tab,Session -仍会成功删除,但该 Tab 会保留为断线状态,以免整个 Hub 随弹窗一起退出。 +搜索必须按 `/` 显式进入,因此普通字母不会误触命令;搜索页输入文字即可筛选,Esc 清空并 +返回。键盘还支持上下键、Page Up/Down、Enter、`Ctrl+R` 刷新和 Esc 返回/关闭。窄屏会优先 +显示主机和 Session 名,状态详情可在 `[⋯]` 菜单中查看。 + +永久删除只会出现在已停止的命名 Session 的菜单中,无论是否仍有关联 Hub Tab 都可执行。 +确认后会在后台通过 SSH 执行 `herdr session delete`,永久删除工作现场和历史数据;关联 Tab +也会一并关闭。运行中的 Session 需先“关闭并停止”,Herdr 永久保留的 `default` 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`。 +提供的本地图片剪贴板桥接。空 Windows 主机或平台检测失败时,可在“添加远端”中选择 +“Windows / SSH”,也可手动执行 `herdr-hub open --remote TARGET --transport ssh`。 也可通过 CLI 管理远端: diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index 511577d..d441c14 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -1230,6 +1230,10 @@ def command_choose(arguments: argparse.Namespace) -> int: remote_registry = make_registry() ssh_bin = resolve_ssh() + def close_window(window_id: str) -> list[CatalogWindow]: + hub.dismiss_window(window_id) + return catalog_windows(hub.managed_windows()) + def rename_session(endpoint_key: str, alias: str) -> None: alias_registry.set(endpoint_key, alias) if hub.apply_alias(endpoint_key, alias) is None: @@ -1296,6 +1300,7 @@ def command_choose(arguments: argparse.Namespace) -> int: local_sessions=(endpoint.session for endpoint in local_report.endpoints), aliases=hub.aliases, rename_handler=rename_session, + close_window_handler=close_window, delete_session_handler=delete_session, stop_session_handler=stop_session, ) diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index bb1c913..1f3bac9 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -113,6 +113,7 @@ class StopSessionOutcome: DeleteSessionHandler = Callable[[str, str, str | None], DeleteSessionOutcome | None] StopSessionHandler = Callable[[str, str, str], StopSessionOutcome | list[CatalogWindow]] +CloseWindowHandler = Callable[[str], Iterable[CatalogWindow]] @dataclass(frozen=True) @@ -183,6 +184,8 @@ class PickerRow: remote_transport: str = "native" endpoint_key: str | None = None deletable: bool = False + running: bool | None = None + error: str | None = None def build_main_rows( @@ -199,17 +202,17 @@ def build_main_rows( registered = {entry.target for entry in entry_list} rows: list[PickerRow] = [] - standalone = [ - window - for window in window_list - if window.kind == "local" - or window.target is None - or window.target not in registered - ] - if standalone: - rows.append(PickerRow("heading:open", "heading", "已打开", selectable=False)) - for window in standalone: - rows.append(_window_row(window)) + standalone = sorted( + [ + window + for window in window_list + if window.kind != "remote" + or window.target is None + or window.target not in registered + ], + key=lambda window: (not window.active, window.label.casefold()), + ) + rows.extend(_window_row(window) for window in standalone) open_local_sessions = { window.session @@ -224,62 +227,17 @@ def build_main_rows( }, key=lambda name: (name != "default", name), ) - if available_local_sessions: - rows.append(PickerRow("heading:local", "heading", "本机", selectable=False)) - rows.extend( - PickerRow( - f"local:{session}", - "local", - f"○ {session}", - "未在 Tab 中 · 单击打开", - session=session, - ) - for session in available_local_sessions - ) - - active_window = next((window for window in window_list if window.active), None) - if active_window is not None: - can_close = len(window_list) > 1 - rows.append( - PickerRow( - "close-active", - "close", - f"− 关闭当前 Tab:{active_window.label}", - "Herdr Session 保持运行" if can_close else "至少保留一个 Session Tab", - selectable=can_close, - window_id=active_window.window_id, - ) - ) - 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 显示名称…", - "重命名或恢复默认", - ) + rows.extend( + PickerRow( + f"local:{session}", + "local", + f"○ {_local_session_label(session)}", + "未打开", + session=session, + running=True, ) + for session in available_local_sessions + ) remote_windows = { (window.target, window.session): window @@ -290,39 +248,15 @@ def build_main_rows( } 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, - ) - ) + preferred_transport = _preferred_remote_transport(entry, result, window_list) + state_known = result is not None and result.error is None session_by_name = ( { session.name: session for session in result.sessions if is_valid_session_name(session.name) } - if result is not None and result.error is None + if state_known and result is not None else {} ) for target, session_name in remote_windows: @@ -335,13 +269,15 @@ def build_main_rows( ): window = remote_windows.get((entry.target, session_name)) session = session_by_name[session_name] + running: bool | None if session is not None: running = session.running - else: + elif state_known and window is not None: assert window is not None running = not window.dead - state_known = result is not None and result.error is None - deletable = state_known and not running and session_name != "default" + else: + running = None + deletable = running is False and session_name != "default" transport = preferred_transport # Automatic transport reconciliation is intentionally one-way. # A confirmed/manual SSH window must never be downgraded merely @@ -349,18 +285,14 @@ def build_main_rows( if window is not None and not ( transport == "ssh" and window.remote_transport != "ssh" ): - row = _window_row(window) - if state_known and not running: - row = replace( - row, - detail=( - "已停止 · Enter 启动 · Herdr 保留 default" - if session_name == "default" - else "已停止 · Enter 启动 · d/D 删除(同时关闭 Tab)" - ), + rows.append( + _window_row( + window, + running=running, + error=result.error if result is not None else None, deletable=deletable, ) - rows.append(row) + ) continue if window is not None and transport == "ssh": symbol = "↻" @@ -369,42 +301,50 @@ def build_main_rows( symbol = "○" if running else "■" if transport == "ssh": detail = ( - "Windows · SSH 模式 · 单击打开" + "Windows / SSH · 远端运行" if running - else "Windows · 已停止 · Enter 启动 · Herdr 保留 default" + else "Windows / SSH · 已停止 · default 保留项" if session_name == "default" - else "Windows · 已停止 · Enter 启动 · d/D 删除" + else "Windows / SSH · 已停止" ) else: detail = ( - "远端运行 · 单击打开" + "远端运行" if running - else "已停止 · Enter 启动 · Herdr 保留 default" + else "已停止 · default 保留项" if session_name == "default" - else "已停止 · Enter 启动 · d/D 删除" + else "已停止" ) rows.append( PickerRow( f"remote:{entry.target}:{session_name}", "remote", - f"{symbol} {session_name}", + f"{symbol} {_remote_session_label(entry.target, session_name)}", detail, window_id=window.window_id if window is not None else None, target=entry.target, session=session_name, remote_keybindings=entry.remote_keybindings, remote_transport=transport, + endpoint_key=window.key if window is not None else None, deletable=deletable, + running=running, ) ) + if session_by_name: + continue if entry.target in loading_targets: rows.append( PickerRow( f"loading:{entry.target}", "loading", - "… 正在查询 Sessions", + f"… {entry.target}", + "正在查询", selectable=False, + target=entry.target, + remote_keybindings=entry.remote_keybindings, + remote_transport=preferred_transport, ) ) elif result is not None and result.error is not None: @@ -412,32 +352,36 @@ def build_main_rows( PickerRow( f"retry:{entry.target}", "retry", - "! 查询失败 · Enter 重试 · d/D 移除记录", - result.error, + f"! {entry.target}", + "无法连接", target=entry.target, + remote_keybindings=entry.remote_keybindings, + remote_transport=preferred_transport, + error=result.error, ) ) - elif result is not None and not session_by_name: + elif state_known: rows.append( PickerRow( f"empty:{entry.target}", - "empty", - "没有已有 Session", - selectable=False, + "remote-empty", + f"○ {entry.target}", + "没有 Session", + target=entry.target, + remote_keybindings=entry.remote_keybindings, + remote_transport=preferred_transport, ) ) + + if entry_list: rows.append( PickerRow( - f"new:{entry.target}", - "new", + "new-session", + "new-session", "+ 新建 Session…", - entry.target, - target=entry.target, - remote_keybindings=entry.remote_keybindings, - remote_transport=preferred_transport, + "选择远端", ) ) - if registry_error is not None: rows.append( PickerRow( @@ -449,34 +393,82 @@ def build_main_rows( ) ) else: - rows.extend( - [ - PickerRow("add-remote", "add", "+ 添加远端…", "自动/native 模式"), - PickerRow( - "add-ssh-remote", - "add-ssh", - "+ 添加 Windows/SSH 远端…", - "强制 SSH TTY 模式", - remote_transport="ssh", - ), - PickerRow("manage-remotes", "manage", "⚙ 管理远端…"), - ] - ) + rows.append(PickerRow("add-remote", "add-remote", "+ 添加远端…")) return rows -def _window_row(window: CatalogWindow) -> PickerRow: - symbol = "▶" if window.active else "×" if window.dead else "●" - detail = "当前" if window.active else "已断线 · 单击重连" if window.dead else "Hub 已打开" +def _preferred_remote_transport( + entry: RemoteEntry, + result: RemoteQueryResult | None, + windows: Iterable[CatalogWindow], +) -> str: + 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 windows + ) + ): + return "ssh" + return "native" + + +def _local_session_label(session: str) -> str: + return "本机" if session == "default" else f"本机 / {session}" + + +def _remote_session_label(target: str, session: str) -> str: + return target if session == "default" else f"{target} / {session}" + + +def _window_display_label(window: CatalogWindow) -> str: + if window.default_label is not None and window.label != window.default_label: + return window.label + if window.kind == "local" and window.session is not None: + return _local_session_label(window.session) + if ( + window.kind == "remote" + and window.target is not None + and window.session is not None + ): + return _remote_session_label(window.target, window.session) + return window.label + + +def _window_row( + window: CatalogWindow, + *, + running: bool | None = None, + error: str | None = None, + deletable: bool = False, +) -> PickerRow: + if running is False: + symbol = "■" + detail = ( + "已停止 · default 保留项" + if window.session == "default" + else "已停止" + ) + else: + symbol = "▶" if window.active else "×" if window.dead else "●" + detail = "当前" if window.active else "已断线" if window.dead else "已打开" + if error is not None and window.dead: + detail = "无法连接" return PickerRow( f"window:{window.window_id}", "window", - f"{symbol} {window.label}", + f"{symbol} {_window_display_label(window)}", detail, window_id=window.window_id, dead=window.dead, target=window.target, session=window.session, + remote_transport=window.remote_transport, + endpoint_key=window.key, + deletable=deletable, + running=running, + error=error, ) @@ -487,10 +479,38 @@ def filter_rows(rows: Iterable[PickerRow], query: str) -> list[PickerRow]: return [ row for row in rows - if needle in f"{row.main} {row.detail}".casefold() + if needle in f"{row.main} {row.detail} {row.error or ''}".casefold() ] +def _row_subject(row: PickerRow) -> str: + return row.main[2:] if len(row.main) > 2 and row.main[1] == " " else row.main + + +def _window_is_active( + windows: Iterable[CatalogWindow], + window_id: str | None, +) -> bool: + return any(window.window_id == window_id and window.active for window in windows) + + +def _action_row( + source: PickerRow, + suffix: str, + kind: str, + main: str, + detail: str = "", +) -> PickerRow: + return replace( + source, + key=f"action:{suffix}:{source.key}", + kind=kind, + main=main, + detail=detail, + selectable=True, + ) + + class SessionSelector: def __init__( self, @@ -500,6 +520,7 @@ class SessionSelector: local_sessions: Iterable[str] = (), aliases: dict[str, str] | None = None, rename_handler: RenameHandler | None = None, + close_window_handler: CloseWindowHandler | None = None, delete_session_handler: DeleteSessionHandler | None = None, stop_session_handler: StopSessionHandler | None = None, ) -> None: @@ -507,6 +528,7 @@ class SessionSelector: self.local_sessions = list(local_sessions) self.aliases = dict(aliases or {}) self.rename_handler = rename_handler + self.close_window_handler = close_window_handler self.delete_session_handler = delete_session_handler self.stop_session_handler = stop_session_handler self.registry = registry @@ -525,6 +547,8 @@ class SessionSelector: self.selected_key: str | None = None self.scroll = 0 self.view = "main" + self.input_kind: str | None = None + self.confirm_kind: str | None = None self.input_value = "" self.input_target: str | None = None self.input_session: str | None = None @@ -532,7 +556,10 @@ class SessionSelector: self.input_key: str | None = None self.input_remote_keybindings = "local" self.input_remote_transport = "native" + self.context_row: PickerRow | None = None self._drawn_rows: dict[int, PickerRow] = {} + self._action_button: tuple[int, int, int] | None = None + self._row_action_buttons: dict[int, tuple[int, int, PickerRow]] = {} self._load_registry() def _load_registry(self) -> None: @@ -573,7 +600,11 @@ class SessionSelector: self.results[result.entry.target] = result self._poll_action() rows = self._rows() - filtered = filter_rows(rows, self.query) if self.view == "main" else rows + filtered = ( + filter_rows(rows, self.query) + if self.view in {"main", "search"} + else rows + ) self._normalize_selection(filtered) self._draw(screen, filtered) try: @@ -586,130 +617,15 @@ 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( - PickerRow( - 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) - ) - if len(rows) == 1: - rows.append(PickerRow("no-remotes", "empty", "没有已登记远端", selectable=False)) - rows.append(PickerRow("manage-back", "back", "← 返回 Sessions")) - return rows - if self.view == "confirm-remove": - target = self.input_target or "" - return [ - PickerRow( - "confirm-heading", - "heading", - f"移除 {target}?", - "不会关闭已有窗口或远端 server", - selectable=False, - ), - PickerRow("confirm-yes", "confirm-yes", "移除", target=target), - PickerRow("confirm-no", "back", "取消"), - ] - if self.view == "confirm-delete-session": - target = self.input_target or "" - session = self.input_session or "" - return [ - PickerRow( - "delete-session-heading", - "heading", - f"永久删除 {target}/{session}?", - ( - "关联 Hub Tab 也会关闭" - if self.input_window_id is not None - else "Session 已停止" - ), - selectable=False, - ), - PickerRow( - "delete-session-warning", - "empty", - "工作现场和历史数据将无法恢复", - selectable=False, - ), - PickerRow( - "delete-session-yes", - "delete-session-yes", - "确认删除", - window_id=self.input_window_id, - 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"}: + if self.view == "actions": + return self._action_rows() + if self.view == "confirm": + return self._confirmation_rows() + if self.view == "working": 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 "停止" + verb = "删除" if action is not None and action.kind == "delete" else "停止" return [ PickerRow( "working-heading", @@ -719,7 +635,7 @@ class SessionSelector: selectable=False, ) ] - if self.view in {"input-add", "input-new", "input-rename"}: + if self.view == "input": return [] return build_main_rows( self.windows, @@ -730,12 +646,224 @@ class SessionSelector: registry_error=self.registry_error, ) + def _confirmation_rows(self) -> list[PickerRow]: + target = self.input_target or "" + session = self.input_session or "" + if self.confirm_kind == "remove": + return [ + PickerRow( + "confirm-heading", + "heading", + f"移除 {target}?", + "只删除 Hub 记录,不修改远端数据", + selectable=False, + ), + PickerRow("confirm-yes", "confirm-yes", "确认移除", target=target), + PickerRow("confirm-no", "back", "取消"), + ] + if self.confirm_kind == "delete": + return [ + PickerRow( + "confirm-heading", + "heading", + f"永久删除 {target}/{session}?", + ( + "关联 Hub Tab 也会关闭" + if self.input_window_id is not None + else "工作现场和历史数据无法恢复" + ), + selectable=False, + ), + PickerRow( + "confirm-yes", + "delete-session-yes", + "确认永久删除", + window_id=self.input_window_id, + target=target, + session=session, + ), + PickerRow("confirm-no", "back", "取消"), + ] + closes_tab = len(self.windows) > 1 + return [ + PickerRow( + "confirm-heading", + "heading", + f"关闭并停止 {target}/{session}?", + ( + "Hub Tab 将关闭,远端任务会中断" + if closes_tab + else "最后一个 Tab 将保留,远端任务会中断" + ), + selectable=False, + ), + PickerRow( + "confirm-yes", + "stop-session-yes", + "确认停止", + window_id=self.input_window_id, + target=target, + session=session, + ), + PickerRow("confirm-no", "back", "取消"), + ] + + def _action_rows(self) -> list[PickerRow]: + source = self.context_row + if source is None: + return [PickerRow("actions-back", "back", "← 返回 Sessions")] + + if source.kind == "new-session": + rows = [ + PickerRow( + "actions-heading", + "heading", + "在哪台远端新建 Session?", + selectable=False, + ) + ] + for entry in sorted(self.entries.values(), key=lambda item: item.target): + result = self.results.get(entry.target) + transport = _preferred_remote_transport(entry, result, self.windows) + rows.append( + PickerRow( + f"action-new:{entry.target}", + "action-new", + entry.target, + "Windows / SSH" if transport == "ssh" else "自动检测", + target=entry.target, + remote_keybindings=entry.remote_keybindings, + remote_transport=transport, + ) + ) + rows.append(PickerRow("actions-back", "back", "← 返回 Sessions")) + return rows + + if source.kind == "add-remote": + return [ + PickerRow( + "actions-heading", + "heading", + "添加远端", + "选择连接模式", + selectable=False, + ), + PickerRow( + "action-add-auto", + "action-add", + "自动检测(推荐)", + "Linux / macOS", + remote_transport="native", + ), + PickerRow( + "action-add-ssh", + "action-add", + "Windows / SSH", + "强制 SSH TTY 模式", + remote_transport="ssh", + ), + PickerRow("actions-back", "back", "← 返回 Sessions"), + ] + + rows = [ + PickerRow( + "actions-heading", + "heading", + _row_subject(source), + source.error or source.detail, + selectable=False, + ) + ] + if source.kind == "window": + label = ( + "启动并打开" + if source.running is False + else "重新连接" + if source.dead + else "返回当前 Session" + if _window_is_active(self.windows, source.window_id) + else "切换到此 Session" + ) + rows.append(_action_row(source, "open", "action-window", label)) + elif source.kind == "local": + rows.append(_action_row(source, "open", "action-local", "打开 Session")) + elif source.kind == "remote": + label = "启动并打开" if source.running is False else "打开 Session" + rows.append(_action_row(source, "open", "action-remote", label)) + elif source.kind == "retry": + rows.append(_action_row(source, "retry", "action-retry", "重试连接")) + + if source.kind != "retry" and source.error is not None and source.target is not None: + rows.append(_action_row(source, "retry", "action-retry", "重试连接")) + + if source.kind == "window" and source.endpoint_key is not None: + rows.append(_action_row(source, "rename", "action-rename", "修改显示名称")) + if len(self.windows) > 1: + rows.append( + _action_row( + source, + "close", + "action-close", + "关闭 Hub Tab", + "远端 Session 保持运行" if source.target is not None else "", + ) + ) + if ( + source.window_id is not None + and source.target is not None + and source.session is not None + and (source.running is True or (source.running is None and not source.dead)) + ): + rows.append( + _action_row( + source, + "stop", + "action-stop", + "关闭并停止 Session", + "会中断远端任务", + ) + ) + if source.deletable: + rows.append( + _action_row( + source, + "delete", + "action-delete", + "永久删除 Session", + "工作现场无法恢复", + ) + ) + if source.target is not None and source.target in self.entries: + entry = self.entries[source.target] + rows.append( + replace( + _action_row( + source, + "new", + "action-new", + "在此远端新建 Session", + ), + remote_keybindings=entry.remote_keybindings, + ) + ) + rows.append( + _action_row( + source, + "remove", + "action-remove", + "移除远端记录", + "不修改远端数据", + ) + ) + rows.append(PickerRow("actions-back", "back", "← 返回 Sessions")) + return rows + def _handle_key(self, key, rows: list[PickerRow]): # type: ignore[no-untyped-def] if self.action_future is not None: if key == "\x1b": self.message = "远端操作正在进行,完成后可继续管理" return None - if self.view in {"input-add", "input-new", "input-rename"}: + if self.view == "input": return self._handle_input_key(key) if key == curses.KEY_MOUSE: return self._handle_mouse(rows) @@ -751,32 +879,32 @@ class SessionSelector: if key == curses.KEY_NPAGE: self._move(rows, 8) return None + if self.view == "search": + if key in {curses.KEY_ENTER, "\n", "\r"}: + return self._activate(self._selected_row(rows)) + if key == "\x1b": + selected_key = self.selected_key + self.query = "" + self._show_main(selected_key=selected_key) + return None + if key in {curses.KEY_BACKSPACE, "\x7f", "\b"}: + self.query = self.query[:-1] + self.selected_key = None + return None + if isinstance(key, str) and key.isprintable() and len(self.query) < 128: + self.query += key + self.selected_key = None + return None if key in {curses.KEY_ENTER, "\n", "\r"}: return self._activate(self._selected_row(rows)) - if key in {"d", "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 in {"d", "D"} and self.view == "confirm-remove": - row = next( - (item for item in rows if item.kind == "confirm-yes"), - None, - ) - return self._activate(row) - if key in {"d", "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 row is not None and row.kind == "retry" and row.target is not None: - self._show_remove_confirmation(row.target) - return None - if row is not None and row.target is not None and row.session == "default": - self.message = "Herdr 不支持删除 default Session" - else: - self.message = "只能删除已停止的远端 Session" + if key == " " and self.view == "main": + self._show_actions(self._selected_row(rows)) + return None + if key == "/" and self.view == "main": + self.view = "search" + self.query = "" + self.selected_key = None + self.message = "" return None if key == "\x12" and self.view == "main": self.results.clear() @@ -784,53 +912,33 @@ class SessionSelector: self.message = "正在刷新所有远端" return None if key == "\x1b": - if self.view != "main": - self._show_main() - return None - if self.query: - self.query = "" - self.selected_key = None - return None - return False - if key in {curses.KEY_BACKSPACE, "\x7f", "\b"} and self.view == "main": - self.query = self.query[:-1] - self.selected_key = None - return None - if isinstance(key, str) and key.isprintable() and self.view == "main": - if len(self.query) < 128: - self.query += key - self.selected_key = None - return None + return self._go_back() return None 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 + return self._go_back() if key in {curses.KEY_ENTER, "\n", "\r"}: value = self.input_value.strip() - if self.view == "input-add": + if self.input_kind == "add": return self._finish_add(value) - if self.view == "input-rename": + if self.input_kind == "rename": return self._finish_rename(value) return self._finish_new(value) - if key == "\x15" and self.view == "input-rename": + if key == "\x15" and self.input_kind == "rename": self.input_value = "" return None if key in {curses.KEY_BACKSPACE, "\x7f", "\b"}: self.input_value = self.input_value[:-1] return None - limit = MAX_ALIAS_LENGTH if self.view == "input-rename" else 512 + limit = MAX_ALIAS_LENGTH if self.input_kind == "rename" else 512 if isinstance(key, str) and key.isprintable() and len(self.input_value) < limit: self.input_value += key return None def _handle_mouse(self, rows: list[PickerRow]): try: - _, _, y, _, state = curses.getmouse() + _, x, y, _, state = curses.getmouse() except curses.error: return None if state & getattr(curses, "BUTTON4_PRESSED", 0): @@ -845,6 +953,18 @@ class SessionSelector: | getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0) ) if state & activated: + row_action = self._row_action_buttons.get(y) + if row_action is not None: + button_start, button_end, action_row = row_action + if button_start <= x < button_end: + self.selected_key = action_row.key + self._show_actions(action_row) + return None + if self._action_button is not None and self.view in {"main", "search"}: + button_y, button_start, button_end = self._action_button + if y == button_y and button_start <= x < button_end: + self._show_actions(self._selected_row(rows)) + return None row = self._drawn_rows.get(y) if row is not None and row.selectable: self.selected_key = row.key @@ -854,16 +974,19 @@ class SessionSelector: def _activate(self, row: PickerRow | None): if row is None: return None - if row.kind == "window": + if row.kind in {"window", "action-window"}: assert row.window_id is not None return SelectorResult.window(row.window_id, dead=row.dead) - if row.kind == "close": + if row.kind == "action-close": assert row.window_id is not None - return SelectorResult.close(row.window_id) - if row.kind == "local": + if self.close_window_handler is None: + return SelectorResult.close(row.window_id) + self._finish_close_window(row.window_id) + return None + if row.kind in {"local", "action-local"}: assert row.session is not None return SelectorResult.local(row.session) - if row.kind == "remote": + if row.kind in {"remote", "action-remote"}: assert row.target is not None and row.session is not None return SelectorResult.remote( row.target, @@ -871,34 +994,34 @@ class SessionSelector: row.remote_keybindings, row.remote_transport, ) - if row.kind == "retry": + if row.kind in {"new-session", "add-remote", "remote-empty"}: + self._show_actions(row) + elif row.kind in {"retry", "action-retry"}: assert row.target is not None self.results.pop(row.target, None) self.pool.enqueue(self.entries[row.target]) + self._show_main() self.message = f"正在重试 {row.target}" - elif row.kind == "new": - self.view = "input-new" + elif row.kind == "action-new": + self.view = "input" + self.input_kind = "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 in {"add", "add-ssh"}: - self.view = "input-add" + elif row.kind == "action-add": + self.view = "input" + self.input_kind = "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": + elif row.kind == "action-rename": assert row.endpoint_key is not None - self.view = "input-rename" + self.view = "input" + self.input_kind = "rename" self.input_key = row.endpoint_key self.input_target = next( ( @@ -910,21 +1033,24 @@ class SessionSelector: ) self.input_value = self.aliases.get(row.endpoint_key, "") self.message = "" - elif row.kind == "remove": + elif row.kind == "action-remove": assert row.target is not None self._show_remove_confirmation(row.target) - elif row.kind == "stop-session": + elif row.kind == "action-stop": 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.view = "confirm" + self.confirm_kind = "stop" self.input_window_id = row.window_id self.input_target = row.target self.input_session = row.session - self.selected_key = "stop-session-no" + self.selected_key = "confirm-no" self.message = "" + elif row.kind == "action-delete": + self._show_delete_session_confirmation(row) elif row.kind == "confirm-yes": self._finish_remove(row.target or "") elif row.kind == "delete-session-yes": @@ -940,7 +1066,7 @@ class SessionSelector: row.session or "", ) elif row.kind == "back": - self._show_main() + return self._go_back() return None def _finish_add(self, target: str): @@ -1002,7 +1128,19 @@ class SessionSelector: window = replace(window, label=display_label) updated_windows.append(window) self.windows = updated_windows - self._show_manage_sessions(selected_key=f"rename:{endpoint_key}") + selected_key = ( + self.context_row.key + if self.context_row is not None + else next( + ( + f"window:{window.window_id}" + for window in self.windows + if window.key == endpoint_key + ), + None, + ) + ) + self._show_main(selected_key=selected_key) self.message = ( f"已保存:{display_label}" if normalized @@ -1013,19 +1151,53 @@ class SessionSelector: 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.view = "confirm" + self.confirm_kind = "delete" self.input_target = row.target self.input_session = row.session self.input_window_id = row.window_id - self.selected_key = "delete-session-no" + self.selected_key = "confirm-no" self.message = "" def _show_remove_confirmation(self, target: str) -> None: - self.view = "confirm-remove" + self.view = "confirm" + self.confirm_kind = "remove" self.input_target = target self.selected_key = "confirm-no" self.message = "" + def _show_actions(self, row: PickerRow | None) -> None: + if row is None or not row.selectable: + return + self.context_row = row + self.view = "actions" + self.selected_key = None + self.scroll = 0 + self.message = "" + + def _go_back(self): + if self.view == "main": + return False + if self.view == "actions": + selected_key = self.context_row.key if self.context_row is not None else None + self._show_main(selected_key=selected_key) + return None + if self.context_row is not None and self.view in {"confirm", "input"}: + self.view = "actions" + self.input_kind = None + self.confirm_kind = None + self.input_target = None + self.input_session = None + self.input_window_id = None + self.input_value = "" + self.input_key = None + self.selected_key = None + self.scroll = 0 + self.message = "" + return None + self._show_main() + return None + def _finish_delete_session( self, target: str, @@ -1036,7 +1208,7 @@ class SessionSelector: self.message = "Session 删除功能不可用" return self.pending_action = _PendingAction("delete", target, session, window_id) - self.view = "working-delete-session" + self.view = "working" self.selected_key = None self.message = "正在通过 SSH 永久删除 Session…" self.action_future = self.action_executor.submit( @@ -1056,7 +1228,7 @@ class SessionSelector: self.message = "Session 停止功能不可用" return self.pending_action = _PendingAction("stop", target, session, window_id) - self.view = "working-stop-session" + self.view = "working" self.selected_key = None self.message = "正在停止远端 Session…" self.action_future = self.action_executor.submit( @@ -1151,20 +1323,29 @@ class SessionSelector: self._show_main() self.message = f"已移除 {target};已有 window 保持不变" - 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 _finish_close_window(self, window_id: str) -> None: + assert self.close_window_handler is not None + label = next( + ( + _window_display_label(window) + for window in self.windows + if window.window_id == window_id + ), + window_id, + ) + try: + self.windows = list(self.close_window_handler(window_id)) + except (OSError, RuntimeError, ValueError) as error: + self.message = f"关闭 Tab 失败:{error}" + return + self._show_main() + self.message = f"已关闭 Hub Tab:{label};Session 保持运行" - def _show_manage_sessions(self, selected_key: str | None = None) -> None: - self.view = "manage-sessions" + def _show_main(self, selected_key: str | None = None) -> None: + self.view = "main" + self.query = "" + self.input_kind = None + self.confirm_kind = None self.input_target = None self.input_session = None self.input_window_id = None @@ -1172,6 +1353,7 @@ class SessionSelector: self.input_key = None self.input_remote_keybindings = "local" self.input_remote_transport = "native" + self.context_row = None self.selected_key = selected_key self.scroll = 0 @@ -1202,12 +1384,14 @@ class SessionSelector: screen.erase() height, width = screen.getmaxyx() self._drawn_rows = {} + self._action_button = None + self._row_action_buttons = {} if height < 8 or width < 28: self._safe_add(screen, 0, 0, "终端太小,请旋转屏幕", curses.A_BOLD) screen.refresh() return - if self.view in {"input-add", "input-new", "input-rename"}: + if self.view == "input": self._draw_input(screen, height, width) return try: @@ -1217,15 +1401,40 @@ class SessionSelector: 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, "管理远端") + "search": "Sessions", + "actions": "操作", + "confirm": { + "remove": "移除远端记录", + "delete": "永久删除 Session", + "stop": "关闭并停止 Session", + }.get(self.confirm_kind, "确认操作"), + "working": ( + "删除 Session" + if self.pending_action is not None + and self.pending_action.kind == "delete" + else "停止 Session" + ), + }.get(self.view, "Sessions") self._safe_add(screen, 1, 2, title, curses.A_BOLD) - if self.view == "main": - self._safe_add(screen, 2, 2, f"搜索: {self.query}", curses.A_DIM) + if self.view in {"main", "search"}: + action_label = "[操作]" + action_width = terminal_display_width(action_label) + action_x = max(2, width - action_width - 2) + self._safe_add(screen, 1, action_x, action_label, curses.A_BOLD) + self._action_button = (1, action_x, action_x + action_width) + if self.view == "search": + search_line = input_display_line("搜索", self.query, width - 4) + self._safe_add(screen, 2, 2, search_line) + try: + curses.curs_set(1) + screen.move( + 2, + min(width - 2, 2 + terminal_display_width(search_line)), + ) + except curses.error: + pass + else: + self._safe_add(screen, 2, 2, "按 / 搜索 Session", curses.A_DIM) if self.message: self._safe_add(screen, 3, 2, self.message, curses.A_DIM) @@ -1250,23 +1459,68 @@ class SessionSelector: attribute |= curses.A_BOLD elif not row.selectable: attribute |= curses.A_DIM - detail_room = max(0, width // 2 - 2) - main_room = max(8, width - detail_room - 6) + has_row_action = ( + self.view in {"main", "search"} + and row.selectable + and row.kind not in {"new-session", "add-remote"} + ) + action_room = 4 if has_row_action else 0 + if width < 48: + # On phones, identification matters more than repeating state + # text that is also available in the action menu. + detail_room = 0 + main_room = max(8, width - 3 - action_room) + else: + detail_room = min(24, max(8, width // 3)) + main_room = max(8, width - detail_room - 6 - action_room) main = pad_terminal_text(row.main, main_room) detail = truncate_terminal_text(row.detail, detail_room) - line = f" {main} {detail}" + line = f" {main}" if detail_room == 0 else f" {main} {detail}" self._safe_add(screen, y, 0, line, attribute) + if has_row_action: + action_label = "[⋯]" + action_width = terminal_display_width(action_label) + action_x = max(0, width - action_width - 1) + self._safe_add(screen, y, action_x, action_label, attribute) + self._row_action_buttons[y] = ( + action_x, + action_x + action_width, + row, + ) - footer = "Enter 打开/重试 · d/D 删除/移除 · Ctrl+R 刷新 · Esc 关闭" - if self.view in {"working-delete-session", "working-stop-session"}: + footer = ( + "Enter/单击 打开 · Space/[操作] 管理 · / 搜索 · Esc 关闭" + if width >= 64 + else "Enter 打开 · Space 操作" + ) + if self.view == "working": footer = "远端操作进行中,请稍候" + elif self.view == "search": + footer = ( + "输入筛选 · Enter 打开 · Esc 清除" + if width >= 48 + else "输入筛选 · Enter 打开" + ) elif self.view != "main": - footer = "单击/Enter 选择 · Esc 返回" + footer = ( + "单击/Enter 选择 · Esc 返回" + if width >= 48 + else "Enter 选择 · Esc 返回" + ) self._safe_add(screen, height - 1, 1, footer, curses.A_DIM) + if self.view == "search": + search_line = input_display_line("搜索", self.query, width - 4) + try: + screen.move( + 2, + min(width - 2, 2 + terminal_display_width(search_line)), + ) + except curses.error: + pass screen.refresh() def _draw_input(self, screen, height: int, width: int) -> None: # type: ignore[no-untyped-def] - if self.view == "input-rename": + if self.input_kind == "rename": remaining = MAX_ALIAS_LENGTH - len(self.input_value) self._safe_add(screen, 1, 2, "修改 Hub 显示名称", curses.A_BOLD) self._safe_add( @@ -1305,7 +1559,7 @@ class SessionSelector: pass screen.refresh() return - is_add = self.view == "input-add" + is_add = self.input_kind == "add" title = ( "添加 Windows/SSH 远端" if is_add and self.input_remote_transport == "ssh" diff --git a/tests/test_selector.py b/tests/test_selector.py index 7c804c8..b940115 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -37,6 +37,28 @@ def wait_for_action(selector: SessionSelector) -> None: raise AssertionError("selector action did not finish") +class FakeScreen: + def __init__(self, height: int = 12, width: int = 80) -> None: + self.height = height + self.width = width + self.writes: list[tuple[int, int, str]] = [] + + def erase(self) -> None: + self.writes.clear() + + def getmaxyx(self) -> tuple[int, int]: + return self.height, self.width + + def addstr(self, y: int, x: int, value: str, _attribute: int) -> None: + self.writes.append((y, x, value)) + + def move(self, _y: int, _x: int) -> None: + pass + + def refresh(self) -> None: + pass + + class SelectorCatalogTests(unittest.TestCase): def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None: self.assertEqual(terminal_display_width("Rename: work"), 12) @@ -86,17 +108,17 @@ class SelectorCatalogTests(unittest.TestCase): local_sessions=["default", "draft"], ) by_key = {row.key: row for row in rows} - self.assertEqual(by_key["window:@1"].main, "▶ L default") - self.assertEqual(by_key["window:@2"].main, "× R orangepi/work") + self.assertEqual(by_key["window:@1"].main, "▶ 本机") + self.assertEqual(by_key["window:@2"].main, "× 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/D 删除", by_key["remote:orangepi:old"].detail) + self.assertEqual(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) + self.assertIn("new-session", by_key) + self.assertEqual(by_key["local:draft"].main, "○ 本机 / draft") + self.assertNotIn("close-active", by_key) + self.assertNotIn("manage-sessions", by_key) def test_loading_error_and_filter_rows(self) -> None: entry = RemoteEntry("prod") @@ -110,7 +132,9 @@ class SelectorCatalogTests(unittest.TestCase): ) self.assertTrue(any(row.kind == "retry" for row in failed)) retry = next(row for row in failed if row.kind == "retry") - self.assertIn("d/D 移除记录", retry.main) + self.assertEqual(retry.main, "! prod") + self.assertEqual(retry.detail, "无法连接") + self.assertEqual(retry.error, "offline") matches = filter_rows(failed, "offline") self.assertEqual([row.kind for row in matches], ["retry"]) @@ -143,55 +167,61 @@ class SelectorCatalogTests(unittest.TestCase): row = next(item for item in rows if item.key == "window:@2") self.assertTrue(row.deletable) self.assertEqual(row.window_id, "@2") - self.assertIn("同时关闭 Tab", row.detail) + self.assertEqual(row.detail, "已停止") default = next( item for item in rows if item.key == "remote:orangepi:default" ) self.assertFalse(default.deletable) - self.assertIn("Herdr 保留 default", default.detail) + self.assertIn("default 保留项", default.detail) 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) + 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", + ) + selector = SessionSelector( + [local, remote], + registry, + str(Path(directory) / "missing-ssh"), + ) + self.addCleanup(selector.pool.close) + selector.results["orangepi"] = RemoteQueryResult( + RemoteEntry("orangepi"), + sessions=(RemoteSession("work", True),), + ) - 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) + main_rows = selector._rows() # type: ignore[attr-defined] + self.assertFalse(any(row.kind == "stop-session" for row in main_rows)) + remote_row = next(row for row in main_rows if row.key == "window:@2") + selector._show_actions(remote_row) # type: ignore[attr-defined] + actions = selector._rows() # type: ignore[attr-defined] + stop = next(row for row in actions if row.kind == "action-stop") + self.assertEqual(stop.window_id, "@2") + self.assertIn("中断", stop.detail) + self.assertTrue(any(row.kind == "action-close" for row in actions)) + + selector.windows = [remote] + actions = selector._rows() # type: ignore[attr-defined] + self.assertFalse(any(row.kind == "action-close" for row in actions)) + self.assertTrue(any(row.kind == "action-stop" for row in actions)) def test_windows_remote_migrates_to_ssh_transport(self) -> None: window = CatalogWindow( @@ -250,8 +280,9 @@ class SelectorCatalogTests(unittest.TestCase): {"windows": RemoteQueryResult(entry)}, set(), ) - new_row = next(item for item in rows if item.kind == "new") - self.assertEqual(new_row.remote_transport, "ssh") + empty_row = next(item for item in rows if item.kind == "remote-empty") + self.assertEqual(empty_row.remote_transport, "ssh") + self.assertTrue(any(item.kind == "new-session" for item in rows)) def test_windows_new_session_keeps_the_row_transport(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -265,10 +296,16 @@ class SelectorCatalogTests(unittest.TestCase): sessions=(RemoteSession("default", True),), platform="windows", ) + new_menu = next( + item + for item in selector._rows() # type: ignore[attr-defined] + if item.kind == "new-session" + ) + selector._activate(new_menu) # type: ignore[attr-defined] new_row = next( item for item in selector._rows() # type: ignore[attr-defined] - if item.kind == "new" + if item.kind == "action-new" ) selector._activate(new_row) # type: ignore[attr-defined] result = selector._finish_new("work") # type: ignore[attr-defined] @@ -279,7 +316,182 @@ class SelectorCatalogTests(unittest.TestCase): class SelectorActionTests(unittest.TestCase): - def test_unreachable_remote_can_be_removed_with_delete_key(self) -> None: + def test_search_is_explicit_and_escape_preserves_selection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + window = CatalogWindow( + "@1", + "local:default", + "L default", + False, + True, + kind="local", + session="default", + default_label="L default", + ) + selector = SessionSelector( + [window], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + ) + self.addCleanup(selector.pool.close) + rows = selector._rows() # type: ignore[attr-defined] + selector.selected_key = "window:@1" + + selector._handle_key("d", rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "main") + self.assertEqual(selector.query, "") + + selector._handle_key("/", rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "search") + selector._handle_key("本", rows) # type: ignore[attr-defined] + self.assertEqual(selector.query, "本") + selector.selected_key = "window:@1" + selector._handle_key("\x1b", rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "main") + self.assertEqual(selector.query, "") + self.assertEqual(selector.selected_key, "window:@1") + + def test_space_opens_actions_and_escape_returns_to_same_session(self) -> None: + with tempfile.TemporaryDirectory() as directory: + selector = SessionSelector( + [ + CatalogWindow( + "@1", + "local:default", + "L default", + False, + True, + kind="local", + session="default", + ) + ], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + ) + self.addCleanup(selector.pool.close) + rows = selector._rows() # type: ignore[attr-defined] + selector.selected_key = "window:@1" + + selector._handle_key(" ", rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "actions") + self.assertEqual(selector.context_row.key, "window:@1") + selector._handle_key( # type: ignore[attr-defined] + "\x1b", + selector._rows(), # type: ignore[attr-defined] + ) + self.assertEqual(selector.view, "main") + self.assertEqual(selector.selected_key, "window:@1") + + def test_confirmation_defaults_to_cancel_and_escape_returns_to_actions(self) -> None: + with tempfile.TemporaryDirectory() as directory: + registry = RemoteRegistry(Path(directory) / "remotes.json") + entry = RemoteEntry("offline-host") + registry.upsert(entry) + selector = SessionSelector([], registry, "/missing/ssh") + self.addCleanup(selector.pool.close) + selector.results[entry.target] = RemoteQueryResult(entry, error="offline") + source = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.kind == "retry" + ) + selector._show_actions(source) # type: ignore[attr-defined] + remove = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.kind == "action-remove" + ) + selector._activate(remove) # type: ignore[attr-defined] + + self.assertEqual(selector.view, "confirm") + self.assertEqual(selector.selected_key, "confirm-no") + selector._handle_key( # type: ignore[attr-defined] + "\x1b", + selector._rows(), # type: ignore[attr-defined] + ) + self.assertEqual(selector.view, "actions") + self.assertEqual(selector.context_row.key, source.key) + + def test_close_tab_action_updates_list_without_closing_popup(self) -> None: + with tempfile.TemporaryDirectory() as directory: + first = CatalogWindow( + "@1", "local:default", "L default", False, True, + kind="local", session="default", + ) + second = CatalogWindow( + "@2", "local:work", "L work", False, False, + kind="local", session="work", + ) + closed: list[str] = [] + + def close_handler(window_id: str) -> list[CatalogWindow]: + closed.append(window_id) + return [first] + + selector = SessionSelector( + [first, second], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + close_window_handler=close_handler, + ) + self.addCleanup(selector.pool.close) + source = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.key == "window:@2" + ) + selector._show_actions(source) # type: ignore[attr-defined] + close = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.kind == "action-close" + ) + + result = selector._activate(close) # type: ignore[attr-defined] + self.assertIsNone(result) + self.assertEqual(closed, ["@2"]) + self.assertEqual(selector.view, "main") + self.assertEqual(selector.windows, [first]) + self.assertIn("已关闭 Hub Tab", selector.message) + + def test_narrow_layout_prioritizes_name_and_row_menu_is_clickable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + selector = SessionSelector( + [ + CatalogWindow( + "@1", + "local:important-work", + "L important-work", + False, + False, + kind="local", + session="important-work", + ) + ], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + ) + self.addCleanup(selector.pool.close) + rows = selector._rows() # type: ignore[attr-defined] + selector._normalize_selection(rows) # type: ignore[attr-defined] + screen = FakeScreen(width=32) + with patch("herdr_hub.selector.curses.curs_set"): + selector._draw(screen, rows) # type: ignore[attr-defined] + + self.assertFalse(any("已打开" in value for _, _, value in screen.writes)) + self.assertTrue(any("本机 / important" in value for _, _, value in screen.writes)) + button_y, (button_start, _, source) = next( + iter(selector._row_action_buttons.items()) # type: ignore[attr-defined] + ) + with patch( + "herdr_hub.selector.curses.getmouse", + return_value=(0, button_start, button_y, 0, curses.BUTTON1_RELEASED), + ): + selector._handle_mouse(rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "actions") + self.assertEqual(selector.context_row.key, source.key) + + def test_unreachable_remote_can_be_removed_from_its_action_menu(self) -> None: with tempfile.TemporaryDirectory() as directory: registry = RemoteRegistry(Path(directory) / "remotes.json") entry = RemoteEntry("tang1@10.8.0.7") @@ -295,12 +507,19 @@ class SelectorActionTests(unittest.TestCase): rows = selector._rows() # type: ignore[attr-defined] retry = next(item for item in rows if item.kind == "retry") selector.selected_key = retry.key - selector._handle_key("D", rows) # type: ignore[attr-defined] + selector._handle_key(" ", rows) # type: ignore[attr-defined] - self.assertEqual(selector.view, "confirm-remove") + self.assertEqual(selector.view, "actions") + actions = selector._rows() # type: ignore[attr-defined] + remove = next(item for item in actions if item.kind == "action-remove") + selector._activate(remove) # type: ignore[attr-defined] + + self.assertEqual(selector.view, "confirm") + self.assertEqual(selector.confirm_kind, "remove") self.assertEqual(selector.input_target, entry.target) confirm_rows = selector._rows() # type: ignore[attr-defined] - selector._handle_key("d", confirm_rows) # type: ignore[attr-defined] + confirm = next(item for item in confirm_rows if item.kind == "confirm-yes") + selector._activate(confirm) # type: ignore[attr-defined] self.assertEqual(selector.view, "main") self.assertEqual(registry.load(), []) @@ -353,20 +572,27 @@ class SelectorActionTests(unittest.TestCase): sessions=(RemoteSession("work", True),), ) + remote_row = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.key == "window:@2" + ) + selector._show_actions(remote_row) # type: ignore[attr-defined] stop_row = next( row for row in selector._rows() # type: ignore[attr-defined] - if row.kind == "stop-session" + if row.kind == "action-stop" ) selector._activate(stop_row) # type: ignore[attr-defined] - self.assertEqual(selector.view, "confirm-stop-session") + self.assertEqual(selector.view, "confirm") + self.assertEqual(selector.confirm_kind, "stop") 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") + self.assertEqual(selector.view, "working") wait_for_action(selector) self.assertEqual(stopped, [("@2", "orangepi", "work")]) @@ -403,17 +629,30 @@ class SelectorActionTests(unittest.TestCase): 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") + selector._handle_key(" ", rows) # type: ignore[attr-defined] + self.assertEqual(selector.view, "actions") + self.assertFalse( + any( + item.kind == "action-delete" + for item in selector._rows() # type: ignore[attr-defined] + ) + ) self.assertEqual(deleted, []) self.assertEqual(selector.query, "") - self.assertIn("default", selector.message) + selector._go_back() # type: ignore[attr-defined] - selector.query = "" + rows = selector._rows() # type: ignore[attr-defined] 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") + selector._handle_key(" ", rows) # type: ignore[attr-defined] + delete = next( + item + for item in selector._rows() # type: ignore[attr-defined] + if item.kind == "action-delete" + ) + selector._activate(delete) # type: ignore[attr-defined] + self.assertEqual(selector.view, "confirm") + self.assertEqual(selector.confirm_kind, "delete") confirm = next( row for row in selector._rows() # type: ignore[attr-defined] @@ -427,7 +666,7 @@ class SelectorActionTests(unittest.TestCase): selector._handle_mouse( # type: ignore[attr-defined] selector._rows() # type: ignore[attr-defined] ) - self.assertEqual(selector.view, "working-delete-session") + self.assertEqual(selector.view, "working") wait_for_action(selector) self.assertEqual(deleted, [("orangepi", "old", None)]) @@ -490,8 +729,15 @@ class SelectorActionTests(unittest.TestCase): rows = selector._rows() # type: ignore[attr-defined] row = next(item for item in rows if item.key == "window:@2") selector.selected_key = row.key - selector._handle_key("d", rows) # type: ignore[attr-defined] - self.assertEqual(selector.view, "confirm-delete-session") + selector._handle_key(" ", rows) # type: ignore[attr-defined] + delete = next( + item + for item in selector._rows() # type: ignore[attr-defined] + if item.kind == "action-delete" + ) + selector._activate(delete) # type: ignore[attr-defined] + self.assertEqual(selector.view, "confirm") + self.assertEqual(selector.confirm_kind, "delete") confirm = next( item for item in selector._rows() # type: ignore[attr-defined] @@ -545,7 +791,7 @@ class SelectorActionTests(unittest.TestCase): self.assertEqual(window_result, SelectorResult.window("@2", dead=True)) close_result = selector._activate( # type: ignore[attr-defined] - PickerRow("close", "close", "close", window_id="@2") + PickerRow("close", "action-close", "close", window_id="@2") ) self.assertEqual(close_result, SelectorResult.close("@2")) @@ -572,20 +818,20 @@ class SelectorActionTests(unittest.TestCase): 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" - ) + session_row = next( + row + for row in selector._rows() # type: ignore[attr-defined] + if row.key == "window:@3" ) + selector._show_actions(session_row) # type: ignore[attr-defined] rename_row = next( row for row in selector._rows() # type: ignore[attr-defined] - if row.kind == "rename" + if row.kind == "action-rename" ) selector._activate(rename_row) # type: ignore[attr-defined] - self.assertEqual(selector.view, "input-rename") + self.assertEqual(selector.view, "input") + self.assertEqual(selector.input_kind, "rename") self.assertEqual(selector.input_value, "日常工作") self.assertEqual( selector._finish_rename(" 新名称 "), # type: ignore[attr-defined] @@ -605,24 +851,30 @@ class SelectorActionTests(unittest.TestCase): 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_session_row = next( + row + for row in live_selector._rows() # type: ignore[attr-defined] + if row.key == "window:@3" + ) + live_selector._show_actions(live_session_row) # 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" + if row.kind == "action-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.view, "main") + self.assertEqual(live_selector.selected_key, "window:@3") self.assertEqual(live_selector.aliases["local:default"], "连续改名") self.assertIn("已保存", live_selector.message) - selector.view = "input-new" + selector.view = "input" + selector.input_kind = "new" selector.input_target = "orangepi" new_result = selector._finish_new("work") # type: ignore[attr-defined] self.assertEqual( @@ -658,8 +910,16 @@ class SelectorActionTests(unittest.TestCase): output += os.read(descriptor, 4096) self.assertIn(b"Sessions", output) # Empty registries render "+ 添加远端" as the first selectable - # row at terminal row 5. A single SGR mouse click must activate it. + # row. It opens one small connection-mode menu. os.write(descriptor, b"\x1b[<0;5;5M\x1b[<0;5;5m") + mode_menu = b"" + mode_deadline = time.monotonic() + 2 + while time.monotonic() < mode_deadline and b"Linux / macOS" not in mode_menu: + readable, _, _ = select.select([descriptor], [], [], 0.1) + if readable: + mode_menu += os.read(descriptor, 4096) + self.assertIn(b"Linux / macOS", mode_menu) + os.write(descriptor, b"\x1b[<0;5;6M\x1b[<0;5;6m") opened_input = b"" input_deadline = time.monotonic() + 2 while time.monotonic() < input_deadline and b"SSH target" not in opened_input: @@ -667,7 +927,7 @@ class SelectorActionTests(unittest.TestCase): if readable: opened_input += os.read(descriptor, 4096) self.assertIn(b"SSH target", opened_input) - os.write(descriptor, b"\x1b\x1b") + os.write(descriptor, b"\x1b\x1b\x1b") while time.monotonic() < deadline: completed, wait_status = os.waitpid(pid, os.WNOHANG) if completed == pid: