diff --git a/README.md b/README.md index bb06c9d..3437aa0 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ window,同时在后台并行查询所有已登记远端;每台机器的结 | `▶` 当前 / `●` 已打开 | 跳转到已有 window | | `×` 已断线 | 自动重连并跳转 | | `○` 远端运行 | 新建 attach window 并跳转 | -| `■` 远端已停止 | Enter/单击启动;按 `d` 或 `D` 可删除历史 Session | +| `■` 远端已停止 | Enter/单击启动;命名 Session 可按 `d` 或 `D` 删除 | | `↻` Windows 旧窗口 | 原地切换为 SSH TTY 模式并重连 | 弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr @@ -131,10 +131,12 @@ Session”;二次确认后会在后台停止远端 Herdr Session、中断其 记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行, 键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。 -选中标记为 `■ 已停止` 且没有对应 Hub Tab 的远端 Session 后,按 `d` 或 `D` 会进入 -二次确认;可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台通过 SSH 执行 -`herdr session delete`,永久删除该 Session 的工作现场 -和历史数据,并留在弹窗中继续管理。运行中的 Session 和仍有 Tab 的 Session 不允许删除。 +选中已停止的命名远端 Session 后,按 `d` 或 `D` 会进入二次确认;无论是否仍有关联 +Hub Tab 都可以删除。可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台 +通过 SSH 执行 `herdr session delete`,永久删除该 Session 的工作现场和历史数据;若仍有 +关联 Tab,也会一并关闭,然后留在弹窗中继续管理。运行中的 Session 不允许删除;Herdr +自身不支持删除永久保留的 `default` Session。若关联 Tab 是 Hub 的最后一个 Tab,Session +仍会成功删除,但该 Tab 会保留为断线状态,以免整个 Hub 随弹窗一起退出。 Herdr 0.8.2 不支持 Windows 作为原生 `herdr --remote` 的目标主机。Hub 查询到 Windows Session 后会标记为“Windows · SSH 模式”,并自动改为 `ssh -tt target herdr`;命名 diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index c3dfb2e..511577d 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -1235,16 +1235,35 @@ def command_choose(arguments: argparse.Namespace) -> int: 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: + def delete_session( + target: str, + session: str, + window_id: str | None, + ) -> DeleteSessionOutcome: delete_remote_session(ssh_bin, RemoteEntry(target), session) endpoint_key = Endpoint.remote(target, session).key - warning: str | None = None + warnings: list[str] = [] + tab_closed = False + if window_id is not None: + try: + hub.dismiss_window(window_id) + tab_closed = True + except HubError as error: + warnings.append(f"Tab 未关闭:{error}") try: alias_registry.set(endpoint_key, "") except AliasRegistryError as error: - warning = f"Session 已删除,但显示名称清理失败:{error}" + warnings.append(f"显示名称清理失败:{error}") hub.aliases.pop(endpoint_key, None) - return DeleteSessionOutcome(warning) + try: + hub.restore_endpoint(endpoint_key) + except HubError as error: + warnings.append(f"隐藏记录清理失败:{error}") + return DeleteSessionOutcome( + warning=";".join(warnings) or None, + windows=tuple(catalog_windows(hub.managed_windows())), + tab_closed=tab_closed, + ) def stop_session( window_id: str, diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index 7960fb6..146d754 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -23,6 +23,8 @@ RenameHandler = Callable[[str, str], None] @dataclass(frozen=True) class DeleteSessionOutcome: warning: str | None = None + windows: tuple["CatalogWindow", ...] | None = None + tab_closed: bool = False def terminal_display_width(text: str) -> int: @@ -109,7 +111,7 @@ class StopSessionOutcome: warning: str | None = None -DeleteSessionHandler = Callable[[str, str], DeleteSessionOutcome | None] +DeleteSessionHandler = Callable[[str, str, str | None], DeleteSessionOutcome | None] StopSessionHandler = Callable[[str, str, str], StopSessionOutcome | list[CatalogWindow]] @@ -332,6 +334,14 @@ def build_main_rows( key=lambda name: (name != "default", name), ): window = remote_windows.get((entry.target, session_name)) + session = session_by_name[session_name] + if session is not None: + running = session.running + else: + 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" transport = preferred_transport # Automatic transport reconciliation is intentionally one-way. # A confirmed/manual SSH window must never be downgraded merely @@ -339,10 +349,19 @@ def build_main_rows( if window is not None and not ( transport == "ssh" and window.remote_transport != "ssh" ): - rows.append(_window_row(window)) + 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)" + ), + deletable=deletable, + ) + rows.append(row) continue - session = session_by_name[session_name] - running = session.running if session is not None else not window.dead if window is not None and transport == "ssh": symbol = "↻" detail = "Windows · 切换为 SSH 模式" @@ -352,12 +371,16 @@ def build_main_rows( detail = ( "Windows · SSH 模式 · 单击打开" if running + else "Windows · 已停止 · Enter 启动 · Herdr 保留 default" + if session_name == "default" else "Windows · 已停止 · Enter 启动 · d/D 删除" ) else: detail = ( "远端运行 · 单击打开" if running + else "已停止 · Enter 启动 · Herdr 保留 default" + if session_name == "default" else "已停止 · Enter 启动 · d/D 删除" ) rows.append( @@ -366,11 +389,12 @@ def build_main_rows( "remote", f"{symbol} {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, - deletable=not running and window is None, + deletable=deletable, ) ) @@ -632,7 +656,11 @@ class SessionSelector: "delete-session-heading", "heading", f"永久删除 {target}/{session}?", - "只允许删除已停止的 Session", + ( + "关联 Hub Tab 也会关闭" + if self.input_window_id is not None + else "Session 已停止" + ), selectable=False, ), PickerRow( @@ -645,6 +673,7 @@ class SessionSelector: "delete-session-yes", "delete-session-yes", "确认删除", + window_id=self.input_window_id, target=target, session=session, ), @@ -735,7 +764,10 @@ class SessionSelector: if row is not None and row.deletable: self._show_delete_session_confirmation(row) return None - self.message = "只能删除已停止且没有对应 Tab 的远端 Session" + if row is not None and row.target is not None and row.session == "default": + self.message = "Herdr 不支持删除 default Session" + else: + self.message = "只能删除已停止的远端 Session" return None if key == "\x12" and self.view == "main": self.results.clear() @@ -888,7 +920,11 @@ class SessionSelector: 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 "") + self._finish_delete_session( + row.target or "", + row.session or "", + row.window_id, + ) elif row.kind == "stop-session-yes": self._finish_stop_session( row.window_id or "", @@ -972,14 +1008,20 @@ class SessionSelector: self.view = "confirm-delete-session" self.input_target = row.target self.input_session = row.session + self.input_window_id = row.window_id self.selected_key = "delete-session-no" self.message = "" - def _finish_delete_session(self, target: str, session: str) -> None: + def _finish_delete_session( + self, + target: str, + session: str, + window_id: str | None = None, + ) -> None: if self.delete_session_handler is None: self.message = "Session 删除功能不可用" return - self.pending_action = _PendingAction("delete", target, session) + self.pending_action = _PendingAction("delete", target, session, window_id) self.view = "working-delete-session" self.selected_key = None self.message = "正在通过 SSH 永久删除 Session…" @@ -987,6 +1029,7 @@ class SessionSelector: self.delete_session_handler, target, session, + window_id, ) def _finish_stop_session( @@ -1027,6 +1070,13 @@ class SessionSelector: return if action.kind == "delete": + delete_outcome = ( + outcome + if isinstance(outcome, DeleteSessionOutcome) + else DeleteSessionOutcome() + ) + if delete_outcome.windows is not None: + self.windows = list(delete_outcome.windows) result = self.results.get(action.target) if result is not None: self.results[action.target] = replace( @@ -1037,12 +1087,12 @@ class SessionSelector: if item.name != action.session ), ) - warning = ( - outcome.warning - if isinstance(outcome, DeleteSessionOutcome) - else None + warning = delete_outcome.warning + message = ( + f"已关闭 Tab 并删除历史 Session:{action.target}/{action.session}" + if delete_outcome.tab_closed + else f"已删除历史 Session:{action.target}/{action.session}" ) - message = f"已删除历史 Session:{action.target}/{action.session}" else: stop_outcome = ( outcome diff --git a/tests/test_selector.py b/tests/test_selector.py index 1720ccc..47ee38a 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -112,6 +112,42 @@ class SelectorCatalogTests(unittest.TestCase): matches = filter_rows(failed, "offline") self.assertEqual([row.kind for row in matches], ["retry"]) + def test_stopped_remote_with_a_dead_tab_remains_deletable(self) -> None: + window = CatalogWindow( + "@2", + "remote:orangepi:old", + "orangepi/old", + True, + False, + kind="remote", + target="orangepi", + session="old", + ) + result = RemoteQueryResult( + RemoteEntry("orangepi"), + sessions=( + RemoteSession("default", False), + RemoteSession("old", False), + ), + ) + + rows = build_main_rows( + [window], + [RemoteEntry("orangepi")], + {"orangepi": result}, + set(), + ) + + 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) + default = next( + item for item in rows if item.key == "remote:orangepi:default" + ) + self.assertFalse(default.deletable) + self.assertIn("Herdr 保留 default", default.detail) + def test_active_remote_session_has_close_and_stop_action(self) -> None: local = CatalogWindow( "@1", @@ -313,13 +349,13 @@ class SelectorActionTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: registry = RemoteRegistry(Path(directory) / "remotes.json") registry.upsert(RemoteEntry("orangepi")) - deleted: list[tuple[str, str]] = [] + deleted: list[tuple[str, str, str | None]] = [] selector = SessionSelector( [], registry, str(Path(directory) / "missing-ssh"), - delete_session_handler=lambda target, session: deleted.append( - (target, session) + delete_session_handler=lambda target, session, window_id: deleted.append( + (target, session, window_id) ), ) self.addCleanup(selector.pool.close) @@ -341,7 +377,7 @@ class SelectorActionTests(unittest.TestCase): self.assertEqual(selector.view, "main") self.assertEqual(deleted, []) self.assertEqual(selector.query, "") - self.assertIn("只能删除", selector.message) + self.assertIn("default", selector.message) selector.query = "" old = next(row for row in rows if row.key == "remote:orangepi:old") @@ -364,7 +400,7 @@ class SelectorActionTests(unittest.TestCase): self.assertEqual(selector.view, "working-delete-session") wait_for_action(selector) - self.assertEqual(deleted, [("orangepi", "old")]) + self.assertEqual(deleted, [("orangepi", "old", None)]) self.assertEqual(selector.view, "main") self.assertIn("已删除历史 Session", selector.message) self.assertNotIn( @@ -372,6 +408,73 @@ class SelectorActionTests(unittest.TestCase): [item.name for item in selector.results["orangepi"].sessions], ) + def test_stopped_remote_session_with_tab_is_deleted_and_tab_is_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + registry = RemoteRegistry(Path(directory) / "remotes.json") + registry.upsert(RemoteEntry("orangepi")) + local = CatalogWindow( + "@1", + "local:default", + "local", + False, + True, + kind="local", + session="default", + ) + remote = CatalogWindow( + "@2", + "remote:orangepi:old", + "orangepi/old", + True, + False, + kind="remote", + target="orangepi", + session="old", + ) + deleted: list[tuple[str, str, str | None]] = [] + + def delete_handler( + target: str, + session: str, + window_id: str | None, + ) -> DeleteSessionOutcome: + deleted.append((target, session, window_id)) + return DeleteSessionOutcome( + windows=(local,), + tab_closed=True, + ) + + selector = SessionSelector( + [local, remote], + registry, + str(Path(directory) / "missing-ssh"), + delete_session_handler=delete_handler, + ) + self.addCleanup(selector.pool.close) + self.addCleanup(selector.action_executor.shutdown, wait=True) + selector.results["orangepi"] = RemoteQueryResult( + RemoteEntry("orangepi"), + sessions=(RemoteSession("old", False),), + ) + + 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") + confirm = next( + item + for item in selector._rows() # type: ignore[attr-defined] + if item.kind == "delete-session-yes" + ) + self.assertEqual(confirm.window_id, "@2") + selector._activate(confirm) # type: ignore[attr-defined] + wait_for_action(selector) + + self.assertEqual(deleted, [("orangepi", "old", "@2")]) + self.assertEqual(selector.windows, [local]) + self.assertIn("已关闭 Tab 并删除", selector.message) + def test_delete_partial_success_is_reported_as_warning_not_failure(self) -> None: with tempfile.TemporaryDirectory() as directory: registry = RemoteRegistry(Path(directory) / "remotes.json") @@ -380,8 +483,8 @@ class SelectorActionTests(unittest.TestCase): [], registry, "/missing/ssh", - delete_session_handler=lambda _target, _session: DeleteSessionOutcome( - "显示名称清理失败" + delete_session_handler=lambda _target, _session, _window_id: ( + DeleteSessionOutcome("显示名称清理失败") ), ) self.addCleanup(selector.pool.close)