Allow deleting stopped sessions with stale tabs

This commit is contained in:
2026-09-03 14:05:24 +00:00
parent c65361c009
commit b12f2c4986
4 changed files with 205 additions and 31 deletions
+7 -5
View File
@@ -117,7 +117,7 @@ window,同时在后台并行查询所有已登记远端;每台机器的结
| `▶` 当前 / `●` 已打开 | 跳转到已有 window |
| `×` 已断线 | 自动重连并跳转 |
| `○` 远端运行 | 新建 attach window 并跳转 |
| `■` 远端已停止 | Enter/单击启动;按 `d` 或 `D` 可删除历史 Session |
| `■` 远端已停止 | Enter/单击启动;命名 Session 可按 `d` 或 `D` 删除 |
| `↻` Windows 旧窗口 | 原地切换为 SSH TTY 模式并重连 |
弹窗靠前位置提供“关闭当前 Tab”;它只结束该 Tab 中的 Hub client,底层 Herdr
@@ -131,10 +131,12 @@ Session”;二次确认后会在后台停止远端 Herdr Session、中断其
记忆记录,不关闭已有 window,也不停止远端 Herdr server。手机端单击条目即可执行,
键盘支持上下键、Enter、输入搜索、Backspace、`Ctrl+R` 刷新和 Esc 返回/关闭。
选中标记为 `■ 已停止` 且没有对应 Hub Tab 的远端 Session 后,按 `d` 或 `D` 会进入
二次确认;可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台通过 SSH 执行
`herdr session delete`,永久删除该 Session 的工作现场
和历史数据,并留在弹窗中继续管理。运行中的 Session 和仍有 Tab 的 Session 不允许删除。
选中已停止的命名远端 Session 后,按 `d` 或 `D` 会进入二次确认;无论是否仍有关联
Hub Tab 都可以删除。可点击“确认删除”、按 Enter,或再按一次 `d`/`D`。确认后在后台
通过 SSH 执行 `herdr session delete`,永久删除该 Session 的工作现场和历史数据;若仍有
关联 Tab,也会一并关闭,然后留在弹窗中继续管理。运行中的 Session 不允许删除;Herdr
自身不支持删除永久保留的 `default` Session。若关联 Tab 是 Hub 的最后一个 Tab,Session
仍会成功删除,但该 Tab 会保留为断线状态,以免整个 Hub 随弹窗一起退出。
Herdr 0.8.2 不支持 Windows 作为原生 `herdr --remote` 的目标主机。Hub 查询到 Windows
Session 后会标记为“Windows · SSH 模式”,并自动改为 `ssh -tt target herdr`;命名
+23 -4
View File
@@ -1235,16 +1235,35 @@ def command_choose(arguments: argparse.Namespace) -> int:
if hub.apply_alias(endpoint_key, alias) is None:
raise HubError("the selected Session is no longer open")
def delete_session(target: str, session: str) -> DeleteSessionOutcome:
def delete_session(
target: str,
session: str,
window_id: str | None,
) -> DeleteSessionOutcome:
delete_remote_session(ssh_bin, RemoteEntry(target), session)
endpoint_key = Endpoint.remote(target, session).key
warning: str | None = None
warnings: list[str] = []
tab_closed = False
if window_id is not None:
try:
hub.dismiss_window(window_id)
tab_closed = True
except HubError as error:
warnings.append(f"Tab 未关闭:{error}")
try:
alias_registry.set(endpoint_key, "")
except AliasRegistryError as error:
warning = f"Session 已删除,但显示名称清理失败:{error}"
warnings.append(f"显示名称清理失败:{error}")
hub.aliases.pop(endpoint_key, None)
return DeleteSessionOutcome(warning)
try:
hub.restore_endpoint(endpoint_key)
except HubError as error:
warnings.append(f"隐藏记录清理失败:{error}")
return DeleteSessionOutcome(
warning=";".join(warnings) or None,
windows=tuple(catalog_windows(hub.managed_windows())),
tab_closed=tab_closed,
)
def stop_session(
window_id: str,
+65 -15
View File
@@ -23,6 +23,8 @@ RenameHandler = Callable[[str, str], None]
@dataclass(frozen=True)
class DeleteSessionOutcome:
warning: str | None = None
windows: tuple["CatalogWindow", ...] | None = None
tab_closed: bool = False
def terminal_display_width(text: str) -> int:
@@ -109,7 +111,7 @@ class StopSessionOutcome:
warning: str | None = None
DeleteSessionHandler = Callable[[str, str], DeleteSessionOutcome | None]
DeleteSessionHandler = Callable[[str, str, str | None], DeleteSessionOutcome | None]
StopSessionHandler = Callable[[str, str, str], StopSessionOutcome | list[CatalogWindow]]
@@ -332,6 +334,14 @@ def build_main_rows(
key=lambda name: (name != "default", name),
):
window = remote_windows.get((entry.target, session_name))
session = session_by_name[session_name]
if session is not None:
running = session.running
else:
assert window is not None
running = not window.dead
state_known = result is not None and result.error is None
deletable = state_known and not running and session_name != "default"
transport = preferred_transport
# Automatic transport reconciliation is intentionally one-way.
# A confirmed/manual SSH window must never be downgraded merely
@@ -339,10 +349,19 @@ def build_main_rows(
if window is not None and not (
transport == "ssh" and window.remote_transport != "ssh"
):
rows.append(_window_row(window))
row = _window_row(window)
if state_known and not running:
row = replace(
row,
detail=(
"已停止 · Enter 启动 · Herdr 保留 default"
if session_name == "default"
else "已停止 · Enter 启动 · d/D 删除(同时关闭 Tab)"
),
deletable=deletable,
)
rows.append(row)
continue
session = session_by_name[session_name]
running = session.running if session is not None else not window.dead
if window is not None and transport == "ssh":
symbol = "↻"
detail = "Windows · 切换为 SSH 模式"
@@ -352,12 +371,16 @@ def build_main_rows(
detail = (
"Windows · SSH 模式 · 单击打开"
if running
else "Windows · 已停止 · Enter 启动 · Herdr 保留 default"
if session_name == "default"
else "Windows · 已停止 · Enter 启动 · d/D 删除"
)
else:
detail = (
"远端运行 · 单击打开"
if running
else "已停止 · Enter 启动 · Herdr 保留 default"
if session_name == "default"
else "已停止 · Enter 启动 · d/D 删除"
)
rows.append(
@@ -366,11 +389,12 @@ def build_main_rows(
"remote",
f"{symbol} {session_name}",
detail,
window_id=window.window_id if window is not None else None,
target=entry.target,
session=session_name,
remote_keybindings=entry.remote_keybindings,
remote_transport=transport,
deletable=not running and window is None,
deletable=deletable,
)
)
@@ -632,7 +656,11 @@ class SessionSelector:
"delete-session-heading",
"heading",
f"永久删除 {target}/{session}?",
"只允许删除已停止的 Session",
(
"关联 Hub Tab 也会关闭"
if self.input_window_id is not None
else "Session 已停止"
),
selectable=False,
),
PickerRow(
@@ -645,6 +673,7 @@ class SessionSelector:
"delete-session-yes",
"delete-session-yes",
"确认删除",
window_id=self.input_window_id,
target=target,
session=session,
),
@@ -735,7 +764,10 @@ class SessionSelector:
if row is not None and row.deletable:
self._show_delete_session_confirmation(row)
return None
self.message = "只能删除已停止且没有对应 Tab 的远端 Session"
if row is not None and row.target is not None and row.session == "default":
self.message = "Herdr 不支持删除 default Session"
else:
self.message = "只能删除已停止的远端 Session"
return None
if key == "\x12" and self.view == "main":
self.results.clear()
@@ -888,7 +920,11 @@ class SessionSelector:
elif row.kind == "confirm-yes":
self._finish_remove(row.target or "")
elif row.kind == "delete-session-yes":
self._finish_delete_session(row.target or "", row.session or "")
self._finish_delete_session(
row.target or "",
row.session or "",
row.window_id,
)
elif row.kind == "stop-session-yes":
self._finish_stop_session(
row.window_id or "",
@@ -972,14 +1008,20 @@ class SessionSelector:
self.view = "confirm-delete-session"
self.input_target = row.target
self.input_session = row.session
self.input_window_id = row.window_id
self.selected_key = "delete-session-no"
self.message = ""
def _finish_delete_session(self, target: str, session: str) -> None:
def _finish_delete_session(
self,
target: str,
session: str,
window_id: str | None = None,
) -> None:
if self.delete_session_handler is None:
self.message = "Session 删除功能不可用"
return
self.pending_action = _PendingAction("delete", target, session)
self.pending_action = _PendingAction("delete", target, session, window_id)
self.view = "working-delete-session"
self.selected_key = None
self.message = "正在通过 SSH 永久删除 Session…"
@@ -987,6 +1029,7 @@ class SessionSelector:
self.delete_session_handler,
target,
session,
window_id,
)
def _finish_stop_session(
@@ -1027,6 +1070,13 @@ class SessionSelector:
return
if action.kind == "delete":
delete_outcome = (
outcome
if isinstance(outcome, DeleteSessionOutcome)
else DeleteSessionOutcome()
)
if delete_outcome.windows is not None:
self.windows = list(delete_outcome.windows)
result = self.results.get(action.target)
if result is not None:
self.results[action.target] = replace(
@@ -1037,12 +1087,12 @@ class SessionSelector:
if item.name != action.session
),
)
warning = (
outcome.warning
if isinstance(outcome, DeleteSessionOutcome)
else None
warning = delete_outcome.warning
message = (
f"已关闭 Tab 并删除历史 Session:{action.target}/{action.session}"
if delete_outcome.tab_closed
else f"已删除历史 Session:{action.target}/{action.session}"
)
message = f"已删除历史 Session:{action.target}/{action.session}"
else:
stop_outcome = (
outcome
+110 -7
View File
@@ -112,6 +112,42 @@ class SelectorCatalogTests(unittest.TestCase):
matches = filter_rows(failed, "offline")
self.assertEqual([row.kind for row in matches], ["retry"])
def test_stopped_remote_with_a_dead_tab_remains_deletable(self) -> None:
window = CatalogWindow(
"@2",
"remote:orangepi:old",
"orangepi/old",
True,
False,
kind="remote",
target="orangepi",
session="old",
)
result = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(
RemoteSession("default", False),
RemoteSession("old", False),
),
)
rows = build_main_rows(
[window],
[RemoteEntry("orangepi")],
{"orangepi": result},
set(),
)
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)
default = next(
item for item in rows if item.key == "remote:orangepi:default"
)
self.assertFalse(default.deletable)
self.assertIn("Herdr 保留 default", default.detail)
def test_active_remote_session_has_close_and_stop_action(self) -> None:
local = CatalogWindow(
"@1",
@@ -313,13 +349,13 @@ class SelectorActionTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi"))
deleted: list[tuple[str, str]] = []
deleted: list[tuple[str, str, str | None]] = []
selector = SessionSelector(
[],
registry,
str(Path(directory) / "missing-ssh"),
delete_session_handler=lambda target, session: deleted.append(
(target, session)
delete_session_handler=lambda target, session, window_id: deleted.append(
(target, session, window_id)
),
)
self.addCleanup(selector.pool.close)
@@ -341,7 +377,7 @@ class SelectorActionTests(unittest.TestCase):
self.assertEqual(selector.view, "main")
self.assertEqual(deleted, [])
self.assertEqual(selector.query, "")
self.assertIn("只能删除", selector.message)
self.assertIn("default", selector.message)
selector.query = ""
old = next(row for row in rows if row.key == "remote:orangepi:old")
@@ -364,7 +400,7 @@ class SelectorActionTests(unittest.TestCase):
self.assertEqual(selector.view, "working-delete-session")
wait_for_action(selector)
self.assertEqual(deleted, [("orangepi", "old")])
self.assertEqual(deleted, [("orangepi", "old", None)])
self.assertEqual(selector.view, "main")
self.assertIn("已删除历史 Session", selector.message)
self.assertNotIn(
@@ -372,6 +408,73 @@ class SelectorActionTests(unittest.TestCase):
[item.name for item in selector.results["orangepi"].sessions],
)
def test_stopped_remote_session_with_tab_is_deleted_and_tab_is_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
registry.upsert(RemoteEntry("orangepi"))
local = CatalogWindow(
"@1",
"local:default",
"local",
False,
True,
kind="local",
session="default",
)
remote = CatalogWindow(
"@2",
"remote:orangepi:old",
"orangepi/old",
True,
False,
kind="remote",
target="orangepi",
session="old",
)
deleted: list[tuple[str, str, str | None]] = []
def delete_handler(
target: str,
session: str,
window_id: str | None,
) -> DeleteSessionOutcome:
deleted.append((target, session, window_id))
return DeleteSessionOutcome(
windows=(local,),
tab_closed=True,
)
selector = SessionSelector(
[local, remote],
registry,
str(Path(directory) / "missing-ssh"),
delete_session_handler=delete_handler,
)
self.addCleanup(selector.pool.close)
self.addCleanup(selector.action_executor.shutdown, wait=True)
selector.results["orangepi"] = RemoteQueryResult(
RemoteEntry("orangepi"),
sessions=(RemoteSession("old", False),),
)
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")
confirm = next(
item
for item in selector._rows() # type: ignore[attr-defined]
if item.kind == "delete-session-yes"
)
self.assertEqual(confirm.window_id, "@2")
selector._activate(confirm) # type: ignore[attr-defined]
wait_for_action(selector)
self.assertEqual(deleted, [("orangepi", "old", "@2")])
self.assertEqual(selector.windows, [local])
self.assertIn("已关闭 Tab 并删除", selector.message)
def test_delete_partial_success_is_reported_as_warning_not_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
registry = RemoteRegistry(Path(directory) / "remotes.json")
@@ -380,8 +483,8 @@ class SelectorActionTests(unittest.TestCase):
[],
registry,
"/missing/ssh",
delete_session_handler=lambda _target, _session: DeleteSessionOutcome(
"显示名称清理失败"
delete_session_handler=lambda _target, _session, _window_id: (
DeleteSessionOutcome("显示名称清理失败")
),
)
self.addCleanup(selector.pool.close)