1349 lines
48 KiB
Python
1349 lines
48 KiB
Python
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
|