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

677 lines
23 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 dataclasses import dataclass
import curses
import re
from typing import Iterable
from .remotes import (
RegistryError,
RemoteEntry,
RemoteQueryPool,
RemoteQueryResult,
RemoteRegistry,
)
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
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
@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"
@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 remote(
cls,
target: str,
session: str,
remote_keybindings: str,
) -> SelectorResult:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
)
@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"
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 SESSION_NAME_RE.fullmatch(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,
)
)
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:
rows.append(
PickerRow(
f"heading:{entry.target}",
"heading",
entry.target,
"远端",
selectable=False,
)
)
result = results.get(entry.target)
session_by_name = (
{
session.name: session
for session in result.sessions
if SESSION_NAME_RE.fullmatch(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))
if window is not None:
rows.append(_window_row(window))
continue
session = session_by_name[session_name]
assert session is not None
rows.append(
PickerRow(
f"remote:{entry.target}:{session_name}",
"remote",
f"{'○' if session.running else '■'} {session_name}",
"远端运行 · 单击打开" if session.running else "已停止 · 单击启动",
target=entry.target,
session=session_name,
remote_keybindings=entry.remote_keybindings,
)
)
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",
"! 查询失败 · 单击重试",
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,
)
)
if registry_error is not None:
rows.append(
PickerRow(
"registry-error",
"error",
"! 远端配置不可用",
registry_error,
selectable=False,
)
)
else:
rows.extend(
[
PickerRow("add-remote", "add", "+ 添加远端…", "SSH target"),
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] = (),
) -> None:
self.windows = list(windows)
self.local_sessions = list(local_sessions)
self.registry = registry
self.pool = RemoteQueryPool(ssh_bin)
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._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()
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
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":
rows = [PickerRow("manage-heading", "heading", "管理远端", selectable=False)]
rows.extend(
PickerRow(
f"remove:{entry.target}",
"remove",
f"− {entry.target}",
"单击后确认移除",
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 in {"input-add", "input-new"}:
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.view in {"input-add", "input-new"}:
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 == "\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":
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)
return self._finish_new(value)
if key in {curses.KEY_BACKSPACE, "\x7f", "\b"}:
self.input_value = self.input_value[:-1]
return None
if isinstance(key, str) and key.isprintable() and len(self.input_value) < 512:
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
clicked = (
getattr(curses, "BUTTON1_CLICKED", 0)
| getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0)
)
if state & clicked:
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,
)
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_value = ""
self.message = ""
elif row.kind == "add":
self.view = "input-add"
self.input_target = None
self.input_value = ""
self.message = ""
elif row.kind == "manage":
self.view = "manage"
self.query = ""
self.selected_key = None
elif row.kind == "remove":
self.view = "confirm-remove"
self.input_target = row.target
self.selected_key = "confirm-no"
elif row.kind == "confirm-yes":
self._finish_remove(row.target or "")
elif row.kind == "back":
self._show_main()
return None
def _finish_add(self, target: str):
try:
entry = RemoteEntry(target)
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 SESSION_NAME_RE.fullmatch(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)
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_value = ""
self.selected_key = None
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"}:
self._draw_input(screen, height, width)
return
try:
curses.curs_set(0)
except curses.error:
pass
title = "Sessions" if self.view == "main" else "管理远端"
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 = row.main[:main_room]
detail = row.detail[:detail_room]
line = f" {main:<{main_room}} {detail}"
self._safe_add(screen, y, 0, line, attribute)
footer = "单击/Enter 打开 · 输入搜索 · Ctrl+R 刷新 · Esc 关闭"
if 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]
is_add = self.view == "input-add"
title = "添加远端" 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)
self._safe_add(screen, 3, 2, f"{prompt}: {self.input_value}")
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, len(prompt) + len(self.input_value) + 4))
except curses.error:
pass
screen.refresh()
@staticmethod
def _safe_add(screen, y: int, x: int, text: str, attribute: int = curses.A_NORMAL) -> None: # type: ignore[no-untyped-def]
height, width = screen.getmaxyx()
if y < 0 or y >= height or x >= width:
return
try:
screen.addnstr(y, x, text, max(0, width - x - 1), attribute)
except curses.error:
pass