Load local sessions in the background for the Sessions popup
command_choose ran `herdr session list --json` (up to its 5 s timeout) before the popup drew anything, contradicting the documented immediate display. Run the listing on a worker thread and hand the selector a Future: the popup appears at once with a "… 本机 正在查询" placeholder, then fills in the local rows and any discovery warning when the result arrives, mirroring how remote hosts already load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+16
-3
@@ -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":
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user