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