Simplify session manager interaction model

This commit is contained in:
2026-09-03 14:42:25 +00:00
parent 58e68b26de
commit 36bee161cb
4 changed files with 1010 additions and 489 deletions
+27 -25
View File
@@ -99,8 +99,8 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
| 扫描新增 sessions | `Ctrl+A r` 或 `herdr-hub refresh` |
| 强制重连当前 window | `Ctrl+A R` |
| 关闭/隐藏当前 Tab | `Ctrl+A q`,确认 `y`;也可在 Sessions 弹窗中选择 |
| 关闭并停止当前远端 Session | Sessions 弹窗 → `关闭并停止当前 Session`;最后一个 Tab 会保留为断线状态 |
| 修改 Tab 显示名称 | Sessions 弹窗 → `管理 Session 显示名称…` |
| 关闭并停止远端 Session | Sessions 弹窗 → 选中 Session → `Space` / `[⋯]` → `关闭并停止 Session` |
| 修改 Tab 显示名称 | Sessions 弹窗 → 选中 Session → `Space` / `[⋯]` → `修改显示名称` |
| detach Hub | `Ctrl+A d` |
| 重新进入 Hub | `herdr-hub` |
| 停止 Hub clients | `herdr-hub stop` |
@@ -110,44 +110,46 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的
window,同时在后台并行查询所有已登记远端;每台机器的结果就绪后原地更新。
状态与选择行为:
主页面只显示 Session 和两个全局入口。大多数主机只有 `default` Session,因此默认只显示
主机名;有命名 Session 时显示为 `主机 / Session`。本机同样显示为“本机”或
“本机 / Session”。状态与主操作如下:
| 状态 | 选择后的行为 |
| --- | --- |
| `▶` 当前 / `●` 已打开 | 跳转到已有 window |
| `×` 已断线 | 自动重连并跳转 |
| `○` 远端运行 | 新建 attach window 并跳转 |
| `■` 远端已停止 | Enter/单击启动;命名 Session 可按 `d` 或 `D` 删除 |
| `■` 远端已停止 | Enter/单击启动并打开 |
| `↻` Windows 旧窗口 | 原地切换为 SSH TTY 模式并重连 |
弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr
Session 和工作现场继续运行。当前 Tab 是远端 Session 时还会显示“关闭并停止当前
Session”;二次确认后会在后台停止远端 Herdr Session、中断其中正在运行的任务并关闭
对应 Tab,但保留为 `■ 已停止`,以后仍可从弹窗重新启动。若它是 Hub 中最后一个 Tab,
停止后会保留该 Tab 为断线状态,避免销毁承载弹窗的最后一个 window。
“管理 Session 显示名称”可给已打开的本地或远端 Tab
设置最多 48 字符的名称;输入留空可恢复默认。每个远端下方都有“新建 Session”入口。
弹窗底部还可添加普通远端、明确添加“Windows/SSH 远端”,或管理已登记远端;移除远端只删除
记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行,
键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。
Enter 或单击一行始终执行它的主操作。管理操作统一放在上下文菜单中:键盘选中一行后按
`Space`,或在手机端点击行尾 `[⋯]`;右上角 `[操作]` 也会打开当前行的菜单。菜单会按
Session 当前状态提供修改显示名称、关闭 Hub Tab、关闭并停止 Session、永久删除已停止的
命名 Session、在此远端新建 Session、重试连接和移除远端记录等可用操作。Esc 逐层返回;
危险操作会二次确认并默认选中“取消”。关闭 Hub Tab 只结束该 Tab 中的 client,底层
Session 和工作现场保持运行;“关闭并停止”则会中断远端任务。若它是 Hub 中最后一个 Tab,
该 Tab 会保留为断线状态,避免销毁承载弹窗的最后一个 window。
远端查询失败时,该远端行支持 Enter 重试,也支持按 `d` 或 `D` 二次确认后从 Hub 的
历史列表移除。这只删除本机保存的远端登记,不会连接或修改不可达主机上的数据;需要时
可再次通过“添加远端”登记。
主页面只有一个“新建 Session”入口,选择远端后再输入 Session 名;“添加远端”会先让你
选择“自动检测(Linux/macOS)”或“Windows/SSH”。修改显示名称支持最多 48 个字符,留空
保存可恢复默认名称,且不会修改底层 Session ID。移除远端记录只删除本机记忆,不关闭已有
Tab,也不停止远端 Herdr server。
选中已停止的命名远端 Session 后,按 `d` 或 `D` 会进入二次确认;无论是否仍有关联
Hub Tab 都可以删除。可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台
通过 SSH 执行 `herdr session delete`,永久删除该 Session 的工作现场和历史数据;若仍有
关联 Tab,也会一并关闭,然后留在弹窗中继续管理。运行中的 Session 不允许删除;Herdr
自身不支持删除永久保留的 `default` Session。若关联 Tab 是 Hub 的最后一个 Tab,Session
仍会成功删除,但该 Tab 会保留为断线状态,以免整个 Hub 随弹窗一起退出。
搜索必须按 `/` 显式进入,因此普通字母不会误触命令;搜索页输入文字即可筛选,Esc 清空并
返回。键盘还支持上下键、Page Up/Down、Enter、`Ctrl+R` 刷新和 Esc 返回/关闭。窄屏会优先
显示主机和 Session 名,状态详情可在 `[⋯]` 菜单中查看。
永久删除只会出现在已停止的命名 Session 的菜单中,无论是否仍有关联 Hub Tab 都可执行。
确认后会在后台通过 SSH 执行 `herdr session delete`,永久删除工作现场和历史数据;关联 Tab
也会一并关闭。运行中的 Session 需先“关闭并停止”,Herdr 永久保留的 `default` Session
不能删除。
Herdr 0.8.2 不支持 Windows 作为原生 `herdr --remote` 的目标主机。Hub 查询到 Windows
Session 后会标记为“Windows · SSH 模式”,并自动改为 `ssh -tt target herdr`;命名
Session 会在远端运行 `herdr --session NAME`。这种模式仍支持 Tab 切换、detach、断线
重连和远端工作现场持久化,但使用远端 Herdr 的 keybindings,也没有原生 thin client
提供的本地图片剪贴板桥接。空 Windows 主机或平台检测失败时,可在弹窗选择
“添加 Windows/SSH 远端”,也可手动执行 `herdr-hub open --remote TARGET --transport ssh`。
提供的本地图片剪贴板桥接。空 Windows 主机或平台检测失败时,可在“添加远端”中选择
“Windows / SSH”,也可手动执行 `herdr-hub open --remote TARGET --transport ssh`。
也可通过 CLI 管理远端:
+5
View File
@@ -1230,6 +1230,10 @@ def command_choose(arguments: argparse.Namespace) -> int:
remote_registry = make_registry()
ssh_bin = resolve_ssh()
def close_window(window_id: str) -> list[CatalogWindow]:
hub.dismiss_window(window_id)
return catalog_windows(hub.managed_windows())
def rename_session(endpoint_key: str, alias: str) -> None:
alias_registry.set(endpoint_key, alias)
if hub.apply_alias(endpoint_key, alias) is None:
@@ -1296,6 +1300,7 @@ def command_choose(arguments: argparse.Namespace) -> int:
local_sessions=(endpoint.session for endpoint in local_report.endpoints),
aliases=hub.aliases,
rename_handler=rename_session,
close_window_handler=close_window,
delete_session_handler=delete_session,
stop_session_handler=stop_session,
)
+633 -379
View File
File diff suppressed because it is too large Load Diff
+345 -85
View File
@@ -37,6 +37,28 @@ def wait_for_action(selector: SessionSelector) -> None:
raise AssertionError("selector action did not finish")
class FakeScreen:
def __init__(self, height: int = 12, width: int = 80) -> None:
self.height = height
self.width = width
self.writes: list[tuple[int, int, str]] = []
def erase(self) -> None:
self.writes.clear()
def getmaxyx(self) -> tuple[int, int]:
return self.height, self.width
def addstr(self, y: int, x: int, value: str, _attribute: int) -> None:
self.writes.append((y, x, value))
def move(self, _y: int, _x: int) -> None:
pass
def refresh(self) -> None:
pass
class SelectorCatalogTests(unittest.TestCase):
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
self.assertEqual(terminal_display_width("Rename: work"), 12)
@@ -86,17 +108,17 @@ class SelectorCatalogTests(unittest.TestCase):
local_sessions=["default", "draft"],
)
by_key = {row.key: row for row in rows}
self.assertEqual(by_key["window:@1"].main, "▶ L default")
self.assertEqual(by_key["window:@2"].main, "× R orangepi/work")
self.assertEqual(by_key["window:@1"].main, "▶ 本机")
self.assertEqual(by_key["window:@2"].main, "× 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/D 删除", by_key["remote:orangepi:old"].detail)
self.assertEqual(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)
self.assertIn("new-session", by_key)
self.assertEqual(by_key["local:draft"].main, "○ 本机 / draft")
self.assertNotIn("close-active", by_key)
self.assertNotIn("manage-sessions", by_key)
def test_loading_error_and_filter_rows(self) -> None:
entry = RemoteEntry("prod")
@@ -110,7 +132,9 @@ class SelectorCatalogTests(unittest.TestCase):
)
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)
self.assertEqual(retry.main, "! prod")
self.assertEqual(retry.detail, "无法连接")
self.assertEqual(retry.error, "offline")
matches = filter_rows(failed, "offline")
self.assertEqual([row.kind for row in matches], ["retry"])
@@ -143,55 +167,61 @@ class SelectorCatalogTests(unittest.TestCase):
row = next(item for item in rows if item.key == "window:@2")
self.assertTrue(row.deletable)
self.assertEqual(row.window_id, "@2")
self.assertIn("同时关闭 Tab", row.detail)
self.assertEqual(row.detail, "已停止")
default = next(
item for item in rows if item.key == "remote:orangepi:default"
)
self.assertFalse(default.deletable)
self.assertIn("Herdr 保留 default", default.detail)
self.assertIn("default 保留项", default.detail)
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)
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",
)
selector = SessionSelector(
[local, remote],
registry,
str(Path(directory) / "missing-ssh"),
)
self.addCleanup(selector.pool.close)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(RemoteSession("work", True),),
)
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)
main_rows = selector._rows() # type: ignore[attr-defined]
self.assertFalse(any(row.kind == "stop-session" for row in main_rows))
remote_row = next(row for row in main_rows if row.key == "window:@2")
selector._show_actions(remote_row) # type: ignore[attr-defined]
actions = selector._rows() # type: ignore[attr-defined]
stop = next(row for row in actions if row.kind == "action-stop")
self.assertEqual(stop.window_id, "@2")
self.assertIn("中断", stop.detail)
self.assertTrue(any(row.kind == "action-close" for row in actions))
selector.windows = [remote]
actions = selector._rows() # type: ignore[attr-defined]
self.assertFalse(any(row.kind == "action-close" for row in actions))
self.assertTrue(any(row.kind == "action-stop" for row in actions))
def test_windows_remote_migrates_to_ssh_transport(self) -> None:
window = CatalogWindow(
@@ -250,8 +280,9 @@ class SelectorCatalogTests(unittest.TestCase):
{"windows": RemoteQueryResult(entry)},
set(),
)
new_row = next(item for item in rows if item.kind == "new")
self.assertEqual(new_row.remote_transport, "ssh")
empty_row = next(item for item in rows if item.kind == "remote-empty")
self.assertEqual(empty_row.remote_transport, "ssh")
self.assertTrue(any(item.kind == "new-session" for item in rows))
def test_windows_new_session_keeps_the_row_transport(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -265,10 +296,16 @@ class SelectorCatalogTests(unittest.TestCase):
sessions=(RemoteSession("default", True),),
platform="windows",
)
new_menu = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "new-session"
)
selector._activate(new_menu) # type: ignore[attr-defined]
new_row = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "new"
if item.kind == "action-new"
)
selector._activate(new_row) # type: ignore[attr-defined]
result = selector._finish_new("work") # type: ignore[attr-defined]
@@ -279,7 +316,182 @@ class SelectorCatalogTests(unittest.TestCase):
class SelectorActionTests(unittest.TestCase):
def test_unreachable_remote_can_be_removed_with_delete_key(self) -> None:
def test_search_is_explicit_and_escape_preserves_selection(self) -> None:
with tempfile.TemporaryDirectory() as directory:
window = CatalogWindow(
"@1",
"local:default",
"L default",
False,
True,
kind="local",
session="default",
default_label="L default",
)
selector = SessionSelector(
[window],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
)
self.addCleanup(selector.pool.close)
rows = selector._rows() # type: ignore[attr-defined]
selector.selected_key = "window:@1"
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "main")
self.assertEqual(selector.query, "")
selector._handle_key("/", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "search")
selector._handle_key("本", rows) # type: ignore[attr-defined]
self.assertEqual(selector.query, "本")
selector.selected_key = "window:@1"
selector._handle_key("\x1b", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "main")
self.assertEqual(selector.query, "")
self.assertEqual(selector.selected_key, "window:@1")
def test_space_opens_actions_and_escape_returns_to_same_session(self) -> None:
with tempfile.TemporaryDirectory() as directory:
selector = SessionSelector(
[
CatalogWindow(
"@1",
"local:default",
"L default",
False,
True,
kind="local",
session="default",
)
],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
)
self.addCleanup(selector.pool.close)
rows = selector._rows() # type: ignore[attr-defined]
selector.selected_key = "window:@1"
selector._handle_key(" ", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "actions")
self.assertEqual(selector.context_row.key, "window:@1")
selector._handle_key( # type: ignore[attr-defined]
"\x1b",
selector._rows(), # type: ignore[attr-defined]
)
self.assertEqual(selector.view, "main")
self.assertEqual(selector.selected_key, "window:@1")
def test_confirmation_defaults_to_cancel_and_escape_returns_to_actions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
entry = RemoteEntry("offline-host")
registry.upsert(entry)
selector = SessionSelector([], registry, "/missing/ssh")
self.addCleanup(selector.pool.close)
selector.results[entry.target] = RemoteQueryResult(entry, error="offline")
source = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "retry"
)
selector._show_actions(source) # type: ignore[attr-defined]
remove = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "action-remove"
)
selector._activate(remove) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm")
self.assertEqual(selector.selected_key, "confirm-no")
selector._handle_key( # type: ignore[attr-defined]
"\x1b",
selector._rows(), # type: ignore[attr-defined]
)
self.assertEqual(selector.view, "actions")
self.assertEqual(selector.context_row.key, source.key)
def test_close_tab_action_updates_list_without_closing_popup(self) -> None:
with tempfile.TemporaryDirectory() as directory:
first = CatalogWindow(
"@1", "local:default", "L default", False, True,
kind="local", session="default",
)
second = CatalogWindow(
"@2", "local:work", "L work", False, False,
kind="local", session="work",
)
closed: list[str] = []
def close_handler(window_id: str) -> list[CatalogWindow]:
closed.append(window_id)
return [first]
selector = SessionSelector(
[first, second],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
close_window_handler=close_handler,
)
self.addCleanup(selector.pool.close)
source = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.key == "window:@2"
)
selector._show_actions(source) # type: ignore[attr-defined]
close = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "action-close"
)
result = selector._activate(close) # type: ignore[attr-defined]
self.assertIsNone(result)
self.assertEqual(closed, ["@2"])
self.assertEqual(selector.view, "main")
self.assertEqual(selector.windows, [first])
self.assertIn("已关闭 Hub Tab", selector.message)
def test_narrow_layout_prioritizes_name_and_row_menu_is_clickable(self) -> None:
with tempfile.TemporaryDirectory() as directory:
selector = SessionSelector(
[
CatalogWindow(
"@1",
"local:important-work",
"L important-work",
False,
False,
kind="local",
session="important-work",
)
],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
)
self.addCleanup(selector.pool.close)
rows = selector._rows() # type: ignore[attr-defined]
selector._normalize_selection(rows) # type: ignore[attr-defined]
screen = FakeScreen(width=32)
with patch("herdr_hub.selector.curses.curs_set"):
selector._draw(screen, rows) # type: ignore[attr-defined]
self.assertFalse(any("已打开" in value for _, _, value in screen.writes))
self.assertTrue(any("本机 / important" in value for _, _, value in screen.writes))
button_y, (button_start, _, source) = next(
iter(selector._row_action_buttons.items()) # type: ignore[attr-defined]
)
with patch(
"herdr_hub.selector.curses.getmouse",
return_value=(0, button_start, button_y, 0, curses.BUTTON1_RELEASED),
):
selector._handle_mouse(rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "actions")
self.assertEqual(selector.context_row.key, source.key)
def test_unreachable_remote_can_be_removed_from_its_action_menu(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
entry = RemoteEntry("tang1@10.8.0.7")
@@ -295,12 +507,19 @@ class SelectorActionTests(unittest.TestCase):
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]
selector._handle_key(" ", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-remove")
self.assertEqual(selector.view, "actions")
actions = selector._rows() # type: ignore[attr-defined]
remove = next(item for item in actions if item.kind == "action-remove")
selector._activate(remove) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm")
self.assertEqual(selector.confirm_kind, "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]
confirm = next(item for item in confirm_rows if item.kind == "confirm-yes")
selector._activate(confirm) # type: ignore[attr-defined]
self.assertEqual(selector.view, "main")
self.assertEqual(registry.load(), [])
@@ -353,20 +572,27 @@ class SelectorActionTests(unittest.TestCase):
sessions=(RemoteSession("work", True),),
)
remote_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.key == "window:@2"
)
selector._show_actions(remote_row) # type: ignore[attr-defined]
stop_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "stop-session"
if row.kind == "action-stop"
)
selector._activate(stop_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-stop-session")
self.assertEqual(selector.view, "confirm")
self.assertEqual(selector.confirm_kind, "stop")
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")
self.assertEqual(selector.view, "working")
wait_for_action(selector)
self.assertEqual(stopped, [("@2", "orangepi", "work")])
@@ -403,17 +629,30 @@ class SelectorActionTests(unittest.TestCase):
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")
selector._handle_key(" ", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "actions")
self.assertFalse(
any(
item.kind == "action-delete"
for item in selector._rows() # type: ignore[attr-defined]
)
)
self.assertEqual(deleted, [])
self.assertEqual(selector.query, "")
self.assertIn("default", selector.message)
selector._go_back() # type: ignore[attr-defined]
selector.query = ""
rows = selector._rows() # type: ignore[attr-defined]
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")
selector._handle_key(" ", rows) # type: ignore[attr-defined]
delete = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "action-delete"
)
selector._activate(delete) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm")
self.assertEqual(selector.confirm_kind, "delete")
confirm = next(
row
for row in selector._rows() # type: ignore[attr-defined]
@@ -427,7 +666,7 @@ class SelectorActionTests(unittest.TestCase):
selector._handle_mouse( # type: ignore[attr-defined]
selector._rows() # type: ignore[attr-defined]
)
self.assertEqual(selector.view, "working-delete-session")
self.assertEqual(selector.view, "working")
wait_for_action(selector)
self.assertEqual(deleted, [("orangepi", "old", None)])
@@ -490,8 +729,15 @@ class SelectorActionTests(unittest.TestCase):
rows = selector._rows() # type: ignore[attr-defined]
row = next(item for item in rows if item.key == "window:@2")
selector.selected_key = row.key
selector._handle_key("d", rows) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm-delete-session")
selector._handle_key(" ", rows) # type: ignore[attr-defined]
delete = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "action-delete"
)
selector._activate(delete) # type: ignore[attr-defined]
self.assertEqual(selector.view, "confirm")
self.assertEqual(selector.confirm_kind, "delete")
confirm = next(
item
for item in selector._rows() # type: ignore[attr-defined]
@@ -545,7 +791,7 @@ class SelectorActionTests(unittest.TestCase):
self.assertEqual(window_result, SelectorResult.window("@2", dead=True))
close_result = selector._activate( # type: ignore[attr-defined]
PickerRow("close", "close", "close", window_id="@2")
PickerRow("close", "action-close", "close", window_id="@2")
)
self.assertEqual(close_result, SelectorResult.close("@2"))
@@ -572,20 +818,20 @@ class SelectorActionTests(unittest.TestCase):
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"
)
session_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.key == "window:@3"
)
selector._show_actions(session_row) # type: ignore[attr-defined]
rename_row = next(
row
for row in selector._rows() # type: ignore[attr-defined]
if row.kind == "rename"
if row.kind == "action-rename"
)
selector._activate(rename_row) # type: ignore[attr-defined]
self.assertEqual(selector.view, "input-rename")
self.assertEqual(selector.view, "input")
self.assertEqual(selector.input_kind, "rename")
self.assertEqual(selector.input_value, "日常工作")
self.assertEqual(
selector._finish_rename(" 新名称 "), # type: ignore[attr-defined]
@@ -605,24 +851,30 @@ class SelectorActionTests(unittest.TestCase):
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_session_row = next(
row
for row in live_selector._rows() # type: ignore[attr-defined]
if row.key == "window:@3"
)
live_selector._show_actions(live_session_row) # 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"
if row.kind == "action-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.view, "main")
self.assertEqual(live_selector.selected_key, "window:@3")
self.assertEqual(live_selector.aliases["local:default"], "连续改名")
self.assertIn("已保存", live_selector.message)
selector.view = "input-new"
selector.view = "input"
selector.input_kind = "new"
selector.input_target = "orangepi"
new_result = selector._finish_new("work") # type: ignore[attr-defined]
self.assertEqual(
@@ -658,8 +910,16 @@ class SelectorActionTests(unittest.TestCase):
output += os.read(descriptor, 4096)
self.assertIn(b"Sessions", output)
# Empty registries render "+ 添加远端" as the first selectable
# row at terminal row 5. A single SGR mouse click must activate it.
# row. It opens one small connection-mode menu.
os.write(descriptor, b"\x1b[<0;5;5M\x1b[<0;5;5m")
mode_menu = b""
mode_deadline = time.monotonic() + 2
while time.monotonic() < mode_deadline and b"Linux / macOS" not in mode_menu:
readable, _, _ = select.select([descriptor], [], [], 0.1)
if readable:
mode_menu += os.read(descriptor, 4096)
self.assertIn(b"Linux / macOS", mode_menu)
os.write(descriptor, b"\x1b[<0;5;6M\x1b[<0;5;6m")
opened_input = b""
input_deadline = time.monotonic() + 2
while time.monotonic() < input_deadline and b"SSH target" not in opened_input:
@@ -667,7 +927,7 @@ class SelectorActionTests(unittest.TestCase):
if readable:
opened_input += os.read(descriptor, 4096)
self.assertIn(b"SSH target", opened_input)
os.write(descriptor, b"\x1b\x1b")
os.write(descriptor, b"\x1b\x1b\x1b")
while time.monotonic() < deadline:
completed, wait_status = os.waitpid(pid, os.WNOHANG)
if completed == pid: