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:
2026-10-03 03:03:09 +00:00
co-authored by Claude Fable 5.1
parent 907813867a
commit 435ba91d1e
3 changed files with 98 additions and 3 deletions
+16 -3
View File
@@ -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":
+35
View File
@@ -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 ""
+47
View File
@@ -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()