Improve remote session management and Windows support

This commit is contained in:
2026-09-03 12:58:24 +00:00
parent 6c2c9672e1
commit 02d83a04bd
12 changed files with 2342 additions and 146 deletions
+379
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import curses
from dataclasses import replace
import os
from pathlib import Path
import pty
@@ -8,19 +10,42 @@ import signal
import tempfile
import time
import unittest
from unittest.mock import patch
from herdr_hub.remotes import RemoteEntry, RemoteQueryResult, RemoteRegistry, RemoteSession
from herdr_hub.selector import (
CatalogWindow,
DeleteSessionOutcome,
PickerRow,
SessionSelector,
SelectorResult,
build_main_rows,
filter_rows,
input_display_line,
pad_terminal_text,
terminal_display_width,
truncate_terminal_text,
)
def wait_for_action(selector: SessionSelector) -> None:
deadline = time.monotonic() + 2
while selector.action_future is not None and time.monotonic() < deadline:
selector._poll_action() # type: ignore[attr-defined]
time.sleep(0.01)
if selector.action_future is not None:
raise AssertionError("selector action did not finish")
class SelectorCatalogTests(unittest.TestCase):
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
self.assertEqual(terminal_display_width("Rename: work"), 12)
self.assertEqual(terminal_display_width("显示名称: 工作"), 14)
self.assertEqual(terminal_display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(truncate_terminal_text("主机abc", 5), "主机a")
self.assertEqual(terminal_display_width(pad_terminal_text("主机", 6)), 6)
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
windows = [
CatalogWindow(
@@ -31,6 +56,7 @@ class SelectorCatalogTests(unittest.TestCase):
True,
kind="local",
session="default",
default_label="L default",
),
CatalogWindow(
"@2",
@@ -41,6 +67,7 @@ class SelectorCatalogTests(unittest.TestCase):
kind="remote",
target="orangepi",
session="work",
default_label="R orangepi/work",
),
]
result = RemoteQueryResult(
@@ -63,9 +90,13 @@ class SelectorCatalogTests(unittest.TestCase):
self.assertEqual(by_key["window:@2"].main, "× R orangepi/work")
self.assertIn("remote:orangepi:default", by_key)
self.assertIn("remote:orangepi:old", by_key)
self.assertTrue(by_key["remote:orangepi:old"].deletable)
self.assertIn("d 删除", by_key["remote:orangepi:old"].detail)
self.assertFalse(by_key["remote:orangepi:default"].deletable)
self.assertIn("new:orangepi", by_key)
self.assertEqual(by_key["local:draft"].main, "○ draft")
self.assertEqual(by_key["close-active"].window_id, "@1")
self.assertIn("manage-sessions", by_key)
def test_loading_error_and_filter_rows(self) -> None:
entry = RemoteEntry("prod")
@@ -81,8 +112,288 @@ class SelectorCatalogTests(unittest.TestCase):
matches = filter_rows(failed, "offline")
self.assertEqual([row.kind for row in matches], ["retry"])
def test_active_remote_session_has_close_and_stop_action(self) -> None:
local = CatalogWindow(
"@1",
"local:default",
"L default",
False,
False,
kind="local",
session="default",
)
remote = CatalogWindow(
"@2",
"remote:orangepi:work",
"R orangepi/work",
False,
True,
kind="remote",
target="orangepi",
session="work",
)
rows = build_main_rows(
[local, remote],
[RemoteEntry("orangepi")],
{},
{"orangepi"},
)
stop = next(row for row in rows if row.kind == "stop-session")
self.assertTrue(stop.selectable)
self.assertEqual(stop.window_id, "@2")
self.assertIn("任务会中断", stop.detail)
last_tab_rows = build_main_rows(
[remote],
[RemoteEntry("orangepi")],
{},
{"orangepi"},
)
last_stop = next(
row for row in last_tab_rows if row.kind == "stop-session"
)
self.assertTrue(last_stop.selectable)
self.assertIn("Tab 将保留", last_stop.detail)
def test_windows_remote_migrates_to_ssh_transport(self) -> None:
window = CatalogWindow(
"@4",
"remote:windows:default",
"R windows/default",
True,
False,
kind="remote",
target="windows",
session="default",
remote_transport="native",
)
result = RemoteQueryResult(
RemoteEntry("windows"),
sessions=(RemoteSession("default", True),),
platform="windows",
)
rows = build_main_rows(
[window],
[RemoteEntry("windows")],
{"windows": result},
set(),
)
remote = next(row for row in rows if row.kind == "remote")
self.assertEqual(remote.remote_transport, "ssh")
self.assertIn("切换为 SSH 模式", remote.detail)
def test_existing_ssh_window_is_never_downgraded_while_query_is_unknown(self) -> None:
entry = RemoteEntry("windows")
window = CatalogWindow(
"@4",
"remote:windows:default",
"windows",
False,
True,
kind="remote",
target="windows",
session="default",
remote_transport="ssh",
)
for results, loading in [
({}, {"windows"}),
({"windows": RemoteQueryResult(entry, error="offline")}, set()),
({"windows": RemoteQueryResult(entry)}, set()),
]:
rows = build_main_rows([window], [entry], results, loading)
row = next(item for item in rows if item.key == "window:@4")
self.assertEqual(row.kind, "window")
def test_explicit_ssh_entry_supports_an_empty_windows_host(self) -> None:
entry = RemoteEntry("windows", remote_transport="ssh")
rows = build_main_rows(
[],
[entry],
{"windows": RemoteQueryResult(entry)},
set(),
)
new_row = next(item for item in rows if item.kind == "new")
self.assertEqual(new_row.remote_transport, "ssh")
def test_windows_new_session_keeps_the_row_transport(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
entry = RemoteEntry("windows")
registry.upsert(entry)
selector = SessionSelector([], registry, "/missing/ssh")
self.addCleanup(selector.pool.close)
selector.results["windows"] = RemoteQueryResult(
entry,
sessions=(RemoteSession("default", True),),
platform="windows",
)
new_row = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "new"
)
selector._activate(new_row) # type: ignore[attr-defined]
result = selector._finish_new("work") # type: ignore[attr-defined]
self.assertEqual(
result,
SelectorResult.remote("windows", "work", "local", "ssh"),
)
class SelectorActionTests(unittest.TestCase):
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")
registry.upsert(RemoteEntry("orangepi"))
local = CatalogWindow(
"@1",
"local:default",
"L default",
False,
False,
kind="local",
session="default",
)
remote = CatalogWindow(
"@2",
"remote:orangepi:work",
"R orangepi/work",
False,
True,
kind="remote",
target="orangepi",
session="work",
)
stopped: list[tuple[str, str, str]] = []
def stop_handler(
window_id: str,
target: str,
session: str,
) -> list[CatalogWindow]:
stopped.append((window_id, target, session))
return [replace(local, active=True)]
selector = SessionSelector(
[local, remote],
registry,
str(Path(directory) / "missing-ssh"),
stop_session_handler=stop_handler,
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(RemoteSession("work", True),),
)
stop_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "stop-session"
)
selector._activate(stop_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-stop-session")
confirm = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "stop-session-yes"
)
selector._activate(confirm) # type: ignore[attr-defined]
self.assertEqual(selector.view, "working-stop-session")
wait_for_action(selector)
self.assertEqual(stopped, [("@2", "orangepi", "work")])
self.assertEqual(selector.view, "main")
self.assertEqual([window.window_id for window in selector.windows], ["@1"])
self.assertFalse(selector.results["orangepi"].sessions[0].running)
self.assertIn("已关闭 Tab 并停止", selector.message)
def test_stopped_remote_session_can_be_deleted_without_closing_popup(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi"))
deleted: list[tuple[str, str]] = []
selector = SessionSelector(
[],
registry,
str(Path(directory) / "missing-ssh"),
delete_session_handler=lambda target, session: deleted.append(
(target, session)
),
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(
RemoteSession("default", True),
RemoteSession("old", False),
),
)
rows = selector._rows() # type: ignore[attr-defined]
running = next(
row for row in rows if row.key == "remote:orangepi:default"
)
selector.selected_key = running.key
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "main")
self.assertEqual(deleted, [])
selector.query = ""
old = next(row for row in rows if row.key == "remote:orangepi:old")
selector.selected_key = old.key
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-delete-session")
confirm = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "delete-session-yes"
)
selector._drawn_rows[6] = confirm # type: ignore[attr-defined]
with patch(
"herdr_hub.selector.curses.getmouse",
return_value=(0, 0, 6, 0, curses.BUTTON1_RELEASED),
):
selector._handle_mouse( # type: ignore[attr-defined]
selector._rows() # type: ignore[attr-defined]
)
self.assertEqual(selector.view, "working-delete-session")
wait_for_action(selector)
self.assertEqual(deleted, [("orangepi", "old")])
self.assertEqual(selector.view, "main")
self.assertIn("已删除历史 Session", selector.message)
self.assertNotIn(
"old",
[item.name for item in selector.results["orangepi"].sessions],
)
def test_delete_partial_success_is_reported_as_warning_not_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("host"))
selector = SessionSelector(
[],
registry,
"/missing/ssh",
delete_session_handler=lambda _target, _session: DeleteSessionOutcome(
"显示名称清理失败"
),
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["host"] = RemoteQueryResult(
RemoteEntry("host"),
sessions=(RemoteSession("old", False),),
)
selector._finish_delete_session("host", "old") # type: ignore[attr-defined]
wait_for_action(selector)
self.assertEqual(selector.view, "main")
self.assertIn("已删除历史 Session", selector.message)
self.assertIn("显示名称清理失败", selector.message)
def test_dead_window_reconnect_action_and_new_session_action(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
@@ -108,6 +419,74 @@ class SelectorActionTests(unittest.TestCase):
)
self.assertEqual(local_result, SelectorResult.local("draft"))
selector = SessionSelector(
[
CatalogWindow(
"@3",
"local:default",
"日常工作",
False,
True,
kind="local",
session="default",
default_label="L default",
)
],
registry,
str(Path(directory) / "missing-ssh"),
aliases={"local:default": "日常工作"},
)
self.addCleanup(selector.pool.close)
selector._activate( # type: ignore[attr-defined]
next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "manage-sessions"
)
)
rename_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "rename"
)
selector._activate(rename_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "input-rename")
self.assertEqual(selector.input_value, "日常工作")
self.assertEqual(
selector._finish_rename(" 新名称 "), # type: ignore[attr-defined]
SelectorResult.rename("local:default", "新名称"),
)
self.assertEqual(
selector._finish_rename(""), # type: ignore[attr-defined]
SelectorResult.rename("local:default", ""),
)
renamed: list[tuple[str, str]] = []
live_selector = SessionSelector(
selector.windows,
registry,
str(Path(directory) / "missing-ssh"),
aliases={"local:default": "日常工作"},
rename_handler=lambda key, alias: renamed.append((key, alias)),
)
self.addCleanup(live_selector.pool.close)
live_selector._show_manage_sessions() # type: ignore[attr-defined]
live_selector._activate( # type: ignore[attr-defined]
next(
row
for row in live_selector._rows() # type: ignore[attr-defined]
if row.kind == "rename"
)
)
self.assertIsNone(
live_selector._finish_rename("连续改名") # type: ignore[attr-defined]
)
self.assertEqual(renamed, [("local:default", "连续改名")])
self.assertEqual(live_selector.view, "manage-sessions")
self.assertEqual(live_selector.selected_key, "rename:local:default")
self.assertEqual(live_selector.aliases["local:default"], "连续改名")
self.assertIn("已保存", live_selector.message)
selector.view = "input-new"
selector.input_target = "orangepi"
new_result = selector._finish_new("work") # type: ignore[attr-defined]