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