diff --git a/README.md b/README.md index 3437aa0..fb6b7e6 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ Session”;二次确认后会在后台停止远端 Herdr Session、中断其 记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行, 键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。 +远端查询失败时,该远端行支持 Enter 重试,也支持按 `d` 或 `D` 二次确认后从 Hub 的 +历史列表移除。这只删除本机保存的远端登记,不会连接或修改不可达主机上的数据;需要时 +可再次通过“添加远端”登记。 + 选中已停止的命名远端 Session 后,按 `d` 或 `D` 会进入二次确认;无论是否仍有关联 Hub Tab 都可以删除。可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台 通过 SSH 执行 `herdr session delete`,永久删除该 Session 的工作现场和历史数据;若仍有 diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index 146d754..bb1c913 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -412,7 +412,7 @@ def build_main_rows( PickerRow( f"retry:{entry.target}", "retry", - "! 查询失败 · 单击重试", + "! 查询失败 · Enter 重试 · d/D 移除记录", result.error, target=entry.target, ) @@ -759,11 +759,20 @@ class SessionSelector: 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: @@ -902,9 +911,8 @@ class SessionSelector: 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" + assert row.target is not None + self._show_remove_confirmation(row.target) elif row.kind == "stop-session": assert ( row.window_id is not None @@ -1012,6 +1020,12 @@ class SessionSelector: self.selected_key = "delete-session-no" self.message = "" + def _show_remove_confirmation(self, target: str) -> None: + self.view = "confirm-remove" + self.input_target = target + self.selected_key = "confirm-no" + self.message = "" + def _finish_delete_session( self, target: str, @@ -1243,7 +1257,7 @@ class SessionSelector: line = f" {main} {detail}" self._safe_add(screen, y, 0, line, attribute) - footer = "Enter 打开 · d/D 删除停止项 · Ctrl+R 刷新 · Esc 关闭" + footer = "Enter 打开/重试 · d/D 删除/移除 · Ctrl+R 刷新 · Esc 关闭" if self.view in {"working-delete-session", "working-stop-session"}: footer = "远端操作进行中,请稍候" elif self.view != "main": diff --git a/tests/test_selector.py b/tests/test_selector.py index 47ee38a..7c804c8 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -109,6 +109,8 @@ class SelectorCatalogTests(unittest.TestCase): set(), ) 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) matches = filter_rows(failed, "offline") self.assertEqual([row.kind for row in matches], ["retry"]) @@ -277,6 +279,34 @@ class SelectorCatalogTests(unittest.TestCase): class SelectorActionTests(unittest.TestCase): + def test_unreachable_remote_can_be_removed_with_delete_key(self) -> None: + with tempfile.TemporaryDirectory() as directory: + registry = RemoteRegistry(Path(directory) / "remotes.json") + entry = RemoteEntry("tang1@10.8.0.7") + registry.upsert(entry) + selector = SessionSelector([], registry, "/missing/ssh") + self.addCleanup(selector.pool.close) + self.addCleanup(selector.action_executor.shutdown, wait=True) + selector.results[entry.target] = RemoteQueryResult( + entry, + error="ssh: connect to host 10.8.0.7 port 22: Connection timed out", + ) + + 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] + + self.assertEqual(selector.view, "confirm-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] + + self.assertEqual(selector.view, "main") + self.assertEqual(registry.load(), []) + self.assertNotIn(entry.target, selector.entries) + self.assertIn("已移除", selector.message) + 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")