Files
herdr-session-switch/herdr_hub/selector.py
T

1349 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, replace
import curses
import unicodedata
from typing import Callable, Iterable
from .aliases import MAX_ALIAS_LENGTH, normalize_alias
from .domain import is_valid_session_name
from .remotes import (
RegistryError,
RemoteEntry,
RemoteQueryPool,
RemoteQueryResult,
RemoteRegistry,
)
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:
"""Return the number of terminal cells occupied by Unicode text."""
width = 0
for character in text:
if unicodedata.combining(character):
continue
if unicodedata.category(character).startswith("C"):
continue
width += 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1
return width
def truncate_terminal_text(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
result: list[str] = []
width = 0
for character in text:
character_width = terminal_display_width(character)
if width + character_width > max_width:
break
result.append(character)
width += character_width
return "".join(result)
def terminal_text_tail(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
start = len(text)
width = 0
for index in range(len(text) - 1, -1, -1):
character_width = terminal_display_width(text[index])
if width + character_width > max_width:
break
start = index
width += character_width
return text[start:]
def pad_terminal_text(text: str, width: int) -> str:
truncated = truncate_terminal_text(text, width)
return truncated + " " * max(0, width - terminal_display_width(truncated))
def input_display_line(prompt: str, value: str, width: int) -> str:
prefix = f"{prompt}: "
if terminal_display_width(prefix) >= width:
return truncate_terminal_text(prefix, width)
value_width = width - terminal_display_width(prefix)
if terminal_display_width(value) <= value_width:
visible_value = value
elif value_width <= 1:
visible_value = "…"[:value_width]
else:
visible_value = "…" + terminal_text_tail(value, value_width - 1)
return prefix + visible_value
class SelectorError(RuntimeError):
"""The interactive selector could not be displayed."""
@dataclass(frozen=True)
class CatalogWindow:
window_id: str
key: str
label: str
dead: bool
active: bool
kind: str | None = None
target: str | None = None
session: str | None = None
default_label: str | None = None
remote_transport: str = "native"
@dataclass(frozen=True)
class StopSessionOutcome:
windows: tuple[CatalogWindow, ...]
tab_closed: bool = True
warning: str | None = None
DeleteSessionHandler = Callable[[str, str, str | None], DeleteSessionOutcome | None]
StopSessionHandler = Callable[[str, str, str], StopSessionOutcome | list[CatalogWindow]]
@dataclass(frozen=True)
class _PendingAction:
kind: str
target: str
session: str
window_id: str | None = None
@dataclass(frozen=True)
class SelectorResult:
kind: str
window_id: str | None = None
dead: bool = False
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
endpoint_key: str | None = None
alias: str | None = None
@classmethod
def window(cls, window_id: str, *, dead: bool) -> SelectorResult:
return cls(kind="window", window_id=window_id, dead=dead)
@classmethod
def close(cls, window_id: str) -> SelectorResult:
return cls(kind="close", window_id=window_id)
@classmethod
def local(cls, session: str) -> SelectorResult:
return cls(kind="local", session=session)
@classmethod
def rename(cls, endpoint_key: str, alias: str) -> SelectorResult:
return cls(kind="rename", endpoint_key=endpoint_key, alias=alias)
@classmethod
def remote(
cls,
target: str,
session: str,
remote_keybindings: str,
remote_transport: str = "native",
) -> SelectorResult:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
remote_transport=remote_transport,
)
@dataclass(frozen=True)
class PickerRow:
key: str
kind: str
main: str
detail: str = ""
selectable: bool = True
window_id: str | None = None
dead: bool = False
target: str | None = None
session: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
endpoint_key: str | None = None
deletable: bool = False
def build_main_rows(
windows: Iterable[CatalogWindow],
entries: Iterable[RemoteEntry],
results: dict[str, RemoteQueryResult],
loading_targets: set[str],
*,
local_sessions: Iterable[str] = (),
registry_error: str | None = None,
) -> list[PickerRow]:
window_list = list(windows)
entry_list = sorted(entries, key=lambda entry: entry.target)
registered = {entry.target for entry in entry_list}
rows: list[PickerRow] = []
standalone = [
window
for window in window_list
if window.kind == "local"
or window.target is None
or window.target not in registered
]
if standalone:
rows.append(PickerRow("heading:open", "heading", "已打开", selectable=False))
for window in standalone:
rows.append(_window_row(window))
open_local_sessions = {
window.session
for window in window_list
if window.kind == "local" and window.session is not None
}
available_local_sessions = sorted(
{
session
for session in local_sessions
if is_valid_session_name(session) and session not in open_local_sessions
},
key=lambda name: (name != "default", name),
)
if available_local_sessions:
rows.append(PickerRow("heading:local", "heading", "本机", selectable=False))
rows.extend(
PickerRow(
f"local:{session}",
"local",
f"○ {session}",
"未在 Tab 中 · 单击打开",
session=session,
)
for session in available_local_sessions
)
active_window = next((window for window in window_list if window.active), None)
if active_window is not None:
can_close = len(window_list) > 1
rows.append(
PickerRow(
"close-active",
"close",
f"− 关闭当前 Tab:{active_window.label}",
"Herdr Session 保持运行" if can_close else "至少保留一个 Session Tab",
selectable=can_close,
window_id=active_window.window_id,
)
)
if (
active_window.kind == "remote"
and active_window.target is not None
and active_window.session is not None
):
rows.append(
PickerRow(
"stop-active",
"stop-session",
f"■ 关闭并停止当前 Session:{active_window.label}",
(
"远端任务会中断"
if can_close
else "远端任务会中断;最后一个 Tab 将保留为断线状态"
),
selectable=True,
window_id=active_window.window_id,
target=active_window.target,
session=active_window.session,
)
)
if window_list:
rows.append(
PickerRow(
"manage-sessions",
"manage-sessions",
"✎ 管理 Session 显示名称…",
"重命名或恢复默认",
)
)
remote_windows = {
(window.target, window.session): window
for window in window_list
if window.kind == "remote"
and window.target is not None
and window.session is not None
}
for entry in entry_list:
result = results.get(entry.target)
preferred_transport = (
"ssh"
if entry.remote_transport == "ssh"
or (result is not None and result.platform == "windows")
or any(
window.target == entry.target and window.remote_transport == "ssh"
for window in window_list
)
else "native"
)
platform_detail = (
"Windows · SSH 模式"
if result is not None and result.platform == "windows"
else "SSH TTY 模式"
if preferred_transport == "ssh"
else "远端"
)
rows.append(
PickerRow(
f"heading:{entry.target}",
"heading",
entry.target,
platform_detail,
selectable=False,
)
)
session_by_name = (
{
session.name: session
for session in result.sessions
if is_valid_session_name(session.name)
}
if result is not None and result.error is None
else {}
)
for target, session_name in remote_windows:
if target == entry.target and session_name is not None:
session_by_name.setdefault(session_name, None)
for session_name in sorted(
session_by_name,
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
# because platform detection is loading, failed or inconclusive.
if window is not None and not (
transport == "ssh" and window.remote_transport != "ssh"
):
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
if window is not None and transport == "ssh":
symbol = "↻"
detail = "Windows · 切换为 SSH 模式"
else:
symbol = "○" if running else "■"
if transport == "ssh":
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(
PickerRow(
f"remote:{entry.target}:{session_name}",
"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=deletable,
)
)
if entry.target in loading_targets:
rows.append(
PickerRow(
f"loading:{entry.target}",
"loading",
"… 正在查询 Sessions",
selectable=False,
)
)
elif result is not None and result.error is not None:
rows.append(
PickerRow(
f"retry:{entry.target}",
"retry",
"! 查询失败 · Enter 重试 · d/D 移除记录",
result.error,
target=entry.target,
)
)
elif result is not None and not session_by_name:
rows.append(
PickerRow(
f"empty:{entry.target}",
"empty",
"没有已有 Session",
selectable=False,
)
)
rows.append(
PickerRow(
f"new:{entry.target}",
"new",
"+ 新建 Session…",
entry.target,
target=entry.target,
remote_keybindings=entry.remote_keybindings,
remote_transport=preferred_transport,
)
)
if registry_error is not None:
rows.append(
PickerRow(
"registry-error",
"error",
"! 远端配置不可用",
registry_error,
selectable=False,
)
)
else:
rows.extend(
[
PickerRow("add-remote", "add", "+ 添加远端…", "自动/native 模式"),
PickerRow(
"add-ssh-remote",
"add-ssh",
"+ 添加 Windows/SSH 远端…",
"强制 SSH TTY 模式",
remote_transport="ssh",
),
PickerRow("manage-remotes", "manage", "⚙ 管理远端…"),
]
)
return rows
def _window_row(window: CatalogWindow) -> PickerRow:
symbol = "▶" if window.active else "×" if window.dead else "●"
detail = "当前" if window.active else "已断线 · 单击重连" if window.dead else "Hub 已打开"
return PickerRow(
f"window:{window.window_id}",
"window",
f"{symbol} {window.label}",
detail,
window_id=window.window_id,
dead=window.dead,
target=window.target,
session=window.session,
)
def filter_rows(rows: Iterable[PickerRow], query: str) -> list[PickerRow]:
if not query:
return list(rows)
needle = query.casefold()
return [
row
for row in rows
if needle in f"{row.main} {row.detail}".casefold()
]
class SessionSelector:
def __init__(
self,
windows: Iterable[CatalogWindow],
registry: RemoteRegistry,
ssh_bin: str,
local_sessions: Iterable[str] = (),
aliases: dict[str, str] | None = None,
rename_handler: RenameHandler | None = None,
delete_session_handler: DeleteSessionHandler | None = None,
stop_session_handler: StopSessionHandler | None = None,
) -> None:
self.windows = list(windows)
self.local_sessions = list(local_sessions)
self.aliases = dict(aliases or {})
self.rename_handler = rename_handler
self.delete_session_handler = delete_session_handler
self.stop_session_handler = stop_session_handler
self.registry = registry
self.pool = RemoteQueryPool(ssh_bin)
self.action_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="herdr-hub-action",
)
self.action_future: Future[object] | None = None
self.pending_action: _PendingAction | None = None
self.entries: dict[str, RemoteEntry] = {}
self.results: dict[str, RemoteQueryResult] = {}
self.registry_error: str | None = None
self.message = ""
self.query = ""
self.selected_key: str | None = None
self.scroll = 0
self.view = "main"
self.input_value = ""
self.input_target: str | None = None
self.input_session: str | None = None
self.input_window_id: str | None = None
self.input_key: str | None = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self._drawn_rows: dict[int, PickerRow] = {}
self._load_registry()
def _load_registry(self) -> None:
try:
entries = self.registry.load()
except RegistryError as error:
self.registry_error = str(error)
return
self.registry_error = None
self.entries = {entry.target: entry for entry in entries}
self.pool.enqueue_many(entries)
def run(self) -> SelectorResult | None:
try:
return curses.wrapper(self._curses_main)
except curses.error as error:
raise SelectorError(f"could not initialize the terminal selector: {error}") from error
finally:
self.pool.close()
self.action_executor.shutdown(wait=False, cancel_futures=True)
def _curses_main(self, screen) -> SelectorResult | None: # type: ignore[no-untyped-def]
screen.keypad(True)
screen.timeout(100)
try:
curses.curs_set(0)
except curses.error:
pass
try:
curses.use_default_colors()
except curses.error:
pass
curses.mouseinterval(150)
curses.mousemask(curses.ALL_MOUSE_EVENTS)
while True:
for result in self.pool.poll():
self.results[result.entry.target] = result
self._poll_action()
rows = self._rows()
filtered = filter_rows(rows, self.query) if self.view == "main" else rows
self._normalize_selection(filtered)
self._draw(screen, filtered)
try:
key = screen.get_wch()
except curses.error:
continue
outcome = self._handle_key(key, filtered)
if isinstance(outcome, SelectorResult) or outcome is False:
return outcome if isinstance(outcome, SelectorResult) else None
def _rows(self) -> list[PickerRow]:
if self.view == "manage-sessions":
rows = [
PickerRow(
"manage-sessions-heading",
"heading",
"管理 Session 显示名称",
"仅影响 Hub,不修改底层 Session ID",
selectable=False,
)
]
rows.extend(
PickerRow(
f"rename:{window.key}",
"rename",
f"✎ {window.label}",
(
f"原名 {window.default_label}"
if window.key in self.aliases
else "使用默认名称"
),
endpoint_key=window.key,
)
for window in sorted(
self.windows,
key=lambda item: (not item.active, item.label.casefold()),
)
)
rows.append(PickerRow("manage-sessions-back", "back", "← 返回 Sessions"))
return rows
if self.view == "manage":
rows = [PickerRow("manage-heading", "heading", "管理远端", selectable=False)]
rows.extend(
PickerRow(
f"remove:{entry.target}",
"remove",
f"− {entry.target}",
(
"SSH TTY · 单击后确认移除"
if entry.remote_transport == "ssh"
else "native/自动 · 单击后确认移除"
),
target=entry.target,
)
for entry in sorted(self.entries.values(), key=lambda item: item.target)
)
if len(rows) == 1:
rows.append(PickerRow("no-remotes", "empty", "没有已登记远端", selectable=False))
rows.append(PickerRow("manage-back", "back", "← 返回 Sessions"))
return rows
if self.view == "confirm-remove":
target = self.input_target or ""
return [
PickerRow(
"confirm-heading",
"heading",
f"移除 {target}?",
"不会关闭已有窗口或远端 server",
selectable=False,
),
PickerRow("confirm-yes", "confirm-yes", "移除", target=target),
PickerRow("confirm-no", "back", "取消"),
]
if self.view == "confirm-delete-session":
target = self.input_target or ""
session = self.input_session or ""
return [
PickerRow(
"delete-session-heading",
"heading",
f"永久删除 {target}/{session}?",
(
"关联 Hub Tab 也会关闭"
if self.input_window_id is not None
else "Session 已停止"
),
selectable=False,
),
PickerRow(
"delete-session-warning",
"empty",
"工作现场和历史数据将无法恢复",
selectable=False,
),
PickerRow(
"delete-session-yes",
"delete-session-yes",
"确认删除",
window_id=self.input_window_id,
target=target,
session=session,
),
PickerRow("delete-session-no", "back", "取消"),
]
if self.view == "confirm-stop-session":
target = self.input_target or ""
session = self.input_session or ""
closes_tab = len(self.windows) > 1
return [
PickerRow(
"stop-session-heading",
"heading",
f"关闭并停止 {target}/{session}?",
(
"Hub Tab 将关闭,远端任务会中断"
if closes_tab
else "最后一个 Tab 将保留为断线状态,远端任务会中断"
),
selectable=False,
),
PickerRow(
"stop-session-yes",
"stop-session-yes",
"确认停止",
window_id=self.input_window_id,
target=target,
session=session,
),
PickerRow("stop-session-no", "back", "取消"),
]
if self.view in {"working-delete-session", "working-stop-session"}:
action = self.pending_action
target = action.target if action is not None else ""
session = action.session if action is not None else ""
verb = "删除" if self.view == "working-delete-session" else "停止"
return [
PickerRow(
"working-heading",
"heading",
f"正在{verb} {target}/{session}",
"SSH 操作进行中,请稍候…",
selectable=False,
)
]
if self.view in {"input-add", "input-new", "input-rename"}:
return []
return build_main_rows(
self.windows,
self.entries.values(),
self.results,
{target for target in self.entries if self.pool.is_loading(target)},
local_sessions=self.local_sessions,
registry_error=self.registry_error,
)
def _handle_key(self, key, rows: list[PickerRow]): # type: ignore[no-untyped-def]
if self.action_future is not None:
if key == "\x1b":
self.message = "远端操作正在进行,完成后可继续管理"
return None
if self.view in {"input-add", "input-new", "input-rename"}:
return self._handle_input_key(key)
if key == curses.KEY_MOUSE:
return self._handle_mouse(rows)
if key in {curses.KEY_UP}:
self._move(rows, -1)
return None
if key in {curses.KEY_DOWN}:
self._move(rows, 1)
return None
if key == curses.KEY_PPAGE:
self._move(rows, -8)
return None
if key == curses.KEY_NPAGE:
self._move(rows, 8)
return None
if key in {curses.KEY_ENTER, "\n", "\r"}:
return self._activate(self._selected_row(rows))
if key in {"d", "D"} and self.view == "confirm-delete-session":
row = next(
(item for item in rows if item.kind == "delete-session-yes"),
None,
)
return self._activate(row)
if key in {"d", "D"} and self.view == "confirm-remove":
row = next(
(item for item in rows if item.kind == "confirm-yes"),
None,
)
return self._activate(row)
if key in {"d", "D"} and self.view == "main":
row = self._selected_row(rows)
if row is not None and row.deletable:
self._show_delete_session_confirmation(row)
return None
if row is not None and row.kind == "retry" and row.target is not None:
self._show_remove_confirmation(row.target)
return None
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()
self.pool.enqueue_many(self.entries.values())
self.message = "正在刷新所有远端"
return None
if key == "\x1b":
if self.view != "main":
self._show_main()
return None
if self.query:
self.query = ""
self.selected_key = None
return None
return False
if key in {curses.KEY_BACKSPACE, "\x7f", "\b"} and self.view == "main":
self.query = self.query[:-1]
self.selected_key = None
return None
if isinstance(key, str) and key.isprintable() and self.view == "main":
if len(self.query) < 128:
self.query += key
self.selected_key = None
return None
return None
def _handle_input_key(self, key): # type: ignore[no-untyped-def]
if key == "\x1b":
if self.view == "input-rename":
self._show_manage_sessions()
return None
self._show_main()
return None
if key in {curses.KEY_ENTER, "\n", "\r"}:
value = self.input_value.strip()
if self.view == "input-add":
return self._finish_add(value)
if self.view == "input-rename":
return self._finish_rename(value)
return self._finish_new(value)
if key == "\x15" and self.view == "input-rename":
self.input_value = ""
return None
if key in {curses.KEY_BACKSPACE, "\x7f", "\b"}:
self.input_value = self.input_value[:-1]
return None
limit = MAX_ALIAS_LENGTH if self.view == "input-rename" else 512
if isinstance(key, str) and key.isprintable() and len(self.input_value) < limit:
self.input_value += key
return None
def _handle_mouse(self, rows: list[PickerRow]):
try:
_, _, y, _, state = curses.getmouse()
except curses.error:
return None
if state & getattr(curses, "BUTTON4_PRESSED", 0):
self._move(rows, -3)
return None
if state & getattr(curses, "BUTTON5_PRESSED", 0):
self._move(rows, 3)
return None
activated = (
getattr(curses, "BUTTON1_RELEASED", 0)
| getattr(curses, "BUTTON1_CLICKED", 0)
| getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0)
)
if state & activated:
row = self._drawn_rows.get(y)
if row is not None and row.selectable:
self.selected_key = row.key
return self._activate(row)
return None
def _activate(self, row: PickerRow | None):
if row is None:
return None
if row.kind == "window":
assert row.window_id is not None
return SelectorResult.window(row.window_id, dead=row.dead)
if row.kind == "close":
assert row.window_id is not None
return SelectorResult.close(row.window_id)
if row.kind == "local":
assert row.session is not None
return SelectorResult.local(row.session)
if row.kind == "remote":
assert row.target is not None and row.session is not None
return SelectorResult.remote(
row.target,
row.session,
row.remote_keybindings,
row.remote_transport,
)
if row.kind == "retry":
assert row.target is not None
self.results.pop(row.target, None)
self.pool.enqueue(self.entries[row.target])
self.message = f"正在重试 {row.target}"
elif row.kind == "new":
self.view = "input-new"
self.input_target = row.target
self.input_remote_keybindings = row.remote_keybindings
self.input_remote_transport = row.remote_transport
self.input_value = ""
self.message = ""
elif row.kind in {"add", "add-ssh"}:
self.view = "input-add"
self.input_target = None
self.input_remote_keybindings = "local"
self.input_remote_transport = row.remote_transport
self.input_value = ""
self.message = ""
elif row.kind == "manage":
self.view = "manage"
self.query = ""
self.selected_key = None
elif row.kind == "manage-sessions":
self._show_manage_sessions()
elif row.kind == "rename":
assert row.endpoint_key is not None
self.view = "input-rename"
self.input_key = row.endpoint_key
self.input_target = next(
(
window.default_label
for window in self.windows
if window.key == row.endpoint_key
),
row.endpoint_key,
)
self.input_value = self.aliases.get(row.endpoint_key, "")
self.message = ""
elif row.kind == "remove":
assert row.target is not None
self._show_remove_confirmation(row.target)
elif row.kind == "stop-session":
assert (
row.window_id is not None
and row.target is not None
and row.session is not None
)
self.view = "confirm-stop-session"
self.input_window_id = row.window_id
self.input_target = row.target
self.input_session = row.session
self.selected_key = "stop-session-no"
self.message = ""
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 "",
row.window_id,
)
elif row.kind == "stop-session-yes":
self._finish_stop_session(
row.window_id or "",
row.target or "",
row.session or "",
)
elif row.kind == "back":
self._show_main()
return None
def _finish_add(self, target: str):
try:
entry = RemoteEntry(
target,
self.input_remote_keybindings,
self.input_remote_transport,
)
self.registry.upsert(entry)
except (ValueError, RegistryError) as error:
self.message = str(error)
return None
self.entries[target] = entry
self.results.pop(target, None)
self.pool.enqueue(entry)
self._show_main()
self.message = f"已添加 {target},正在查询"
return None
def _finish_new(self, session: str):
if not is_valid_session_name(session):
self.message = "Session 名称需为 1-64 位字母、数字、. _ -"
return None
target = self.input_target
assert target is not None
entry = self.entries[target]
return SelectorResult.remote(
target,
session,
entry.remote_keybindings,
self.input_remote_transport,
)
def _finish_rename(self, alias: str):
try:
normalized = normalize_alias(alias)
except ValueError as error:
self.message = str(error)
return None
assert self.input_key is not None
if self.rename_handler is not None:
try:
self.rename_handler(self.input_key, normalized)
except (OSError, RuntimeError, ValueError) as error:
self.message = str(error)
return None
endpoint_key = self.input_key
if normalized:
self.aliases[endpoint_key] = normalized
else:
self.aliases.pop(endpoint_key, None)
display_label = normalized
updated_windows: list[CatalogWindow] = []
for window in self.windows:
if window.key == endpoint_key:
display_label = normalized or window.default_label or window.label
window = replace(window, label=display_label)
updated_windows.append(window)
self.windows = updated_windows
self._show_manage_sessions(selected_key=f"rename:{endpoint_key}")
self.message = (
f"已保存:{display_label}"
if normalized
else f"已恢复默认名称:{display_label}"
)
return None
return SelectorResult.rename(self.input_key, normalized)
def _show_delete_session_confirmation(self, row: PickerRow) -> None:
assert row.deletable and row.target is not None and row.session is not None
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 _show_remove_confirmation(self, target: str) -> None:
self.view = "confirm-remove"
self.input_target = target
self.selected_key = "confirm-no"
self.message = ""
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, window_id)
self.view = "working-delete-session"
self.selected_key = None
self.message = "正在通过 SSH 永久删除 Session…"
self.action_future = self.action_executor.submit(
self.delete_session_handler,
target,
session,
window_id,
)
def _finish_stop_session(
self,
window_id: str,
target: str,
session: str,
) -> None:
if self.stop_session_handler is None:
self.message = "Session 停止功能不可用"
return
self.pending_action = _PendingAction("stop", target, session, window_id)
self.view = "working-stop-session"
self.selected_key = None
self.message = "正在停止远端 Session…"
self.action_future = self.action_executor.submit(
self.stop_session_handler,
window_id,
target,
session,
)
def _poll_action(self) -> None:
future = self.action_future
action = self.pending_action
if future is None or action is None or not future.done():
return
self.action_future = None
self.pending_action = None
try:
outcome = future.result()
except (OSError, RuntimeError, ValueError) as error:
self._show_main()
entry = self.entries.get(action.target)
if entry is not None:
self.pool.enqueue(entry)
self.message = f"{action.target}/{action.session} 操作失败:{error}"
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(
result,
sessions=tuple(
item
for item in result.sessions
if item.name != action.session
),
)
warning = delete_outcome.warning
message = (
f"已关闭 Tab 并删除历史 Session:{action.target}/{action.session}"
if delete_outcome.tab_closed
else f"已删除历史 Session:{action.target}/{action.session}"
)
else:
stop_outcome = (
outcome
if isinstance(outcome, StopSessionOutcome)
else StopSessionOutcome(tuple(outcome))
)
self.windows = list(stop_outcome.windows)
result = self.results.get(action.target)
if result is not None:
self.results[action.target] = replace(
result,
sessions=tuple(
replace(item, running=False)
if item.name == action.session
else item
for item in result.sessions
),
)
warning = stop_outcome.warning
message = (
f"已关闭 Tab 并停止远端 Session:{action.target}/{action.session}"
if stop_outcome.tab_closed
else f"已停止远端 Session:{action.target}/{action.session}"
)
entry = self.entries.get(action.target)
if entry is not None:
self.pool.enqueue(entry)
self._show_main()
self.message = f"{message};{warning}" if warning else message
def _finish_remove(self, target: str) -> None:
try:
self.registry.remove(target)
except RegistryError as error:
self.message = str(error)
self._show_main()
return
self.pool.cancel(target)
self.entries.pop(target, None)
self.results.pop(target, None)
self._show_main()
self.message = f"已移除 {target};已有 window 保持不变"
def _show_main(self) -> None:
self.view = "main"
self.input_target = None
self.input_session = None
self.input_window_id = None
self.input_value = ""
self.input_key = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self.selected_key = None
self.scroll = 0
def _show_manage_sessions(self, selected_key: str | None = None) -> None:
self.view = "manage-sessions"
self.input_target = None
self.input_session = None
self.input_window_id = None
self.input_value = ""
self.input_key = None
self.input_remote_keybindings = "local"
self.input_remote_transport = "native"
self.selected_key = selected_key
self.scroll = 0
def _normalize_selection(self, rows: list[PickerRow]) -> None:
selectable = [row for row in rows if row.selectable]
if not selectable:
self.selected_key = None
self.scroll = 0
return
if self.selected_key not in {row.key for row in selectable}:
self.selected_key = selectable[0].key
def _selected_row(self, rows: list[PickerRow]) -> PickerRow | None:
return next((row for row in rows if row.key == self.selected_key), None)
def _move(self, rows: list[PickerRow], delta: int) -> None:
selectable = [row for row in rows if row.selectable]
if not selectable:
return
current = next(
(index for index, row in enumerate(selectable) if row.key == self.selected_key),
0,
)
next_index = max(0, min(len(selectable) - 1, current + delta))
self.selected_key = selectable[next_index].key
def _draw(self, screen, rows: list[PickerRow]) -> None: # type: ignore[no-untyped-def]
screen.erase()
height, width = screen.getmaxyx()
self._drawn_rows = {}
if height < 8 or width < 28:
self._safe_add(screen, 0, 0, "终端太小,请旋转屏幕", curses.A_BOLD)
screen.refresh()
return
if self.view in {"input-add", "input-new", "input-rename"}:
self._draw_input(screen, height, width)
return
try:
curses.curs_set(0)
except curses.error:
pass
title = {
"main": "Sessions",
"manage-sessions": "管理 Session 显示名称",
"confirm-delete-session": "删除历史 Session",
"confirm-stop-session": "关闭并停止远端 Session",
"working-delete-session": "删除历史 Session",
"working-stop-session": "停止远端 Session",
}.get(self.view, "管理远端")
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
if self.view == "main":
self._safe_add(screen, 2, 2, f"搜索: {self.query}", curses.A_DIM)
if self.message:
self._safe_add(screen, 3, 2, self.message, curses.A_DIM)
list_top = 4
list_height = max(1, height - list_top - 2)
selected_index = next(
(index for index, row in enumerate(rows) if row.key == self.selected_key),
0,
)
if selected_index < self.scroll:
self.scroll = selected_index
elif selected_index >= self.scroll + list_height:
self.scroll = selected_index - list_height + 1
self.scroll = max(0, min(self.scroll, max(0, len(rows) - list_height)))
for offset, row in enumerate(rows[self.scroll : self.scroll + list_height]):
y = list_top + offset
self._drawn_rows[y] = row
selected = row.key == self.selected_key
attribute = curses.A_REVERSE if selected else curses.A_NORMAL
if row.kind == "heading":
attribute |= curses.A_BOLD
elif not row.selectable:
attribute |= curses.A_DIM
detail_room = max(0, width // 2 - 2)
main_room = max(8, width - detail_room - 6)
main = pad_terminal_text(row.main, main_room)
detail = truncate_terminal_text(row.detail, detail_room)
line = f" {main} {detail}"
self._safe_add(screen, y, 0, line, attribute)
footer = "Enter 打开/重试 · d/D 删除/移除 · Ctrl+R 刷新 · Esc 关闭"
if self.view in {"working-delete-session", "working-stop-session"}:
footer = "远端操作进行中,请稍候"
elif self.view != "main":
footer = "单击/Enter 选择 · Esc 返回"
self._safe_add(screen, height - 1, 1, footer, curses.A_DIM)
screen.refresh()
def _draw_input(self, screen, height: int, width: int) -> None: # type: ignore[no-untyped-def]
if self.view == "input-rename":
remaining = MAX_ALIAS_LENGTH - len(self.input_value)
self._safe_add(screen, 1, 2, "修改 Hub 显示名称", curses.A_BOLD)
self._safe_add(
screen,
2,
2,
f"底层 Session:{self.input_target}",
curses.A_DIM,
)
prompt = f"显示名称(剩余 {remaining} 字符)"
input_line = input_display_line(prompt, self.input_value, width - 3)
self._safe_add(screen, 4, 2, input_line)
self._safe_add(
screen,
5,
2,
"留空并确认可恢复默认名称;不会修改底层 Session ID",
curses.A_DIM,
)
if self.message:
self._safe_add(screen, 6, 2, self.message, curses.A_DIM)
self._safe_add(
screen,
height - 1,
1,
"Enter 保存 · Ctrl+U 清空 · Esc 返回",
curses.A_DIM,
)
try:
curses.curs_set(1)
screen.move(
4,
min(width - 2, 2 + terminal_display_width(input_line)),
)
except curses.error:
pass
screen.refresh()
return
is_add = self.view == "input-add"
title = (
"添加 Windows/SSH 远端"
if is_add and self.input_remote_transport == "ssh"
else "添加远端"
if is_add
else f"在 {self.input_target} 新建 Session"
)
prompt = "SSH target" if is_add else "Session 名称"
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
input_line = input_display_line(prompt, self.input_value, width - 3)
self._safe_add(screen, 3, 2, input_line)
if self.message:
self._safe_add(screen, 5, 2, self.message, curses.A_DIM)
self._safe_add(screen, height - 1, 1, "Enter 确认 · Esc 返回", curses.A_DIM)
try:
curses.curs_set(1)
screen.move(
3,
min(width - 2, 2 + terminal_display_width(input_line)),
)
except curses.error:
pass
screen.refresh()
@staticmethod
def _safe_add( # type: ignore[no-untyped-def]
screen,
y: int,
x: int,
text: str,
attribute: int = curses.A_NORMAL,
) -> None:
height, width = screen.getmaxyx()
if y < 0 or y >= height or x >= width:
return
try:
visible = truncate_terminal_text(text, max(0, width - x - 1))
screen.addstr(y, x, visible, attribute)
except curses.error:
pass