diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index de2f5d0..aa28edb 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import curses from dataclasses import dataclass, field, replace @@ -1389,13 +1390,19 @@ def command_open(arguments: argparse.Namespace) -> int: return 0 +def local_session_listing(herdr_bin: str) -> tuple[list[str], list[str]]: + """Background half of the Sessions popup: names and warnings only.""" + report = discover_local_sessions(herdr_bin) + return [endpoint.session for endpoint in report.endpoints], report.warnings + + def command_choose(arguments: argparse.Namespace) -> int: startup_warnings: list[str] = [] hub = make_hub(arguments, warnings=startup_warnings) if not hub.exists(): raise HubError("herdr-hub is not running") windows = hub.managed_windows() - local_report = discover_local_sessions(resolve_herdr()) + herdr_bin = resolve_herdr() registry_warning = remember_remote_endpoints( window.endpoint for window in windows if window.endpoint is not None ) @@ -1403,6 +1410,11 @@ def command_choose(arguments: argparse.Namespace) -> int: alias_registry = make_alias_registry() remote_registry = make_registry() ssh_bin = resolve_ssh() + # `herdr session list` can take seconds; the popup must not wait for it. + local_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="herdr-hub-local" + ) + local_future = local_executor.submit(local_session_listing, herdr_bin) def close_window(window_id: str) -> list[CatalogWindow]: hub.dismiss_window(window_id) @@ -1519,15 +1531,14 @@ def command_choose(arguments: argparse.Namespace) -> int: catalog_windows(windows), remote_registry, ssh_bin, - local_sessions=(endpoint.session for endpoint in local_report.endpoints), aliases=hub.aliases, activate_handler=activate_selection, rename_handler=rename_session, close_window_handler=close_window, delete_session_handler=delete_session, stop_session_handler=stop_session, + pending_local_sessions=local_future, ) - startup_warnings.extend(local_report.warnings) if registry_warning: startup_warnings.append(registry_warning) if startup_warnings: @@ -1535,6 +1546,8 @@ def command_choose(arguments: argparse.Namespace) -> int: result = selector.run() except (RegistryError, SelectorError) as error: raise HubError(str(error)) from error + finally: + local_executor.shutdown(wait=False, cancel_futures=True) if result is None: return 0 if result.kind == "rename": diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index 25afc7b..0a10488 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -196,6 +196,7 @@ def build_main_rows( loading_targets: set[str], *, local_sessions: Iterable[str] = (), + local_loading: bool = False, registry_error: str | None = None, ) -> list[PickerRow]: window_list = list(windows) @@ -239,6 +240,16 @@ def build_main_rows( ) for session in available_local_sessions ) + if local_loading: + rows.append( + PickerRow( + "loading:local", + "loading", + "… 本机", + "正在查询", + selectable=False, + ) + ) remote_windows = { (window.target, window.session): window @@ -525,9 +536,13 @@ class SessionSelector: delete_session_handler: DeleteSessionHandler | None = None, stop_session_handler: StopSessionHandler | None = None, activate_handler: ActivateHandler | None = None, + pending_local_sessions: "Future[tuple[Iterable[str], Iterable[str]]] | None" = None, ) -> None: self.windows = list(windows) self.local_sessions = list(local_sessions) + # Local discovery runs `herdr session list` (up to 5 s); the popup + # draws immediately and fills these rows in when the result arrives. + self.pending_local_sessions = pending_local_sessions self.aliases = dict(aliases or {}) self.activate_handler = activate_handler self.rename_handler = rename_handler @@ -602,6 +617,7 @@ class SessionSelector: while True: for result in self.pool.poll(): self.results[result.entry.target] = result + self._poll_local_sessions() self._poll_action() rows = self._rows() filtered = ( @@ -653,9 +669,28 @@ class SessionSelector: self.results, {target for target in self.entries if self.pool.is_loading(target)}, local_sessions=self.local_sessions, + local_loading=self.pending_local_sessions is not None, registry_error=self.registry_error, ) + def _poll_local_sessions(self) -> None: + future = self.pending_local_sessions + if future is None or not future.done(): + return + self.pending_local_sessions = None + try: + sessions, warnings = future.result() + except Exception as error: # noqa: BLE001 - shown inside the popup + self._append_message(f"本机 Session 查询失败:{error}") + return + self.local_sessions = list(sessions) + warning_list = list(warnings) + if warning_list: + self._append_message("; ".join(warning_list)) + + def _append_message(self, text: str) -> None: + self.message = "; ".join(part for part in (self.message, text) if part) + def _confirmation_rows(self) -> list[PickerRow]: target = self.input_target or "" session = self.input_session or "" diff --git a/tests/test_selector.py b/tests/test_selector.py index 0a69567..3f56360 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -1,5 +1,6 @@ from __future__ import annotations +from concurrent.futures import Future import curses from dataclasses import replace import os @@ -1045,5 +1046,51 @@ class SelectorRobustnessTests(unittest.TestCase): self.assertIn("未登记", selector.message) +class SelectorBackgroundLoadingTests(unittest.TestCase): + def test_local_sessions_arrive_after_the_popup_is_drawn(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pending: Future[tuple[list[str], list[str]]] = Future() + selector = SessionSelector( + [], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + pending_local_sessions=pending, + ) + self.addCleanup(selector.pool.close) + selector.message = "startup" + + # Before the listing finishes the popup shows a placeholder row. + selector._poll_local_sessions() # type: ignore[attr-defined] + kinds = {row.key: row for row in selector._rows()} # type: ignore[attr-defined] + self.assertIn("loading:local", kinds) + self.assertFalse(kinds["loading:local"].selectable) + self.assertNotIn("local:work", kinds) + + pending.set_result((["work", "default"], ["local Herdr session discovery was slow"])) + selector._poll_local_sessions() # type: ignore[attr-defined] + self.assertIsNone(selector.pending_local_sessions) + keys = {row.key for row in selector._rows()} # type: ignore[attr-defined] + self.assertIn("local:work", keys) + self.assertIn("local:default", keys) + self.assertNotIn("loading:local", keys) + self.assertEqual(selector.message, "startup; local Herdr session discovery was slow") + + def test_failed_local_listing_becomes_a_message(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pending: Future[tuple[list[str], list[str]]] = Future() + pending.set_exception(OSError("herdr is not executable")) + selector = SessionSelector( + [], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + pending_local_sessions=pending, + ) + self.addCleanup(selector.pool.close) + selector._poll_local_sessions() # type: ignore[attr-defined] + self.assertIsNone(selector.pending_local_sessions) + self.assertIn("herdr is not executable", selector.message) + self.assertEqual(selector.local_sessions, []) + + if __name__ == "__main__": unittest.main()