Allow deleting stopped sessions with stale tabs
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user