""" 多标签终端容器 - 每个标签是一个独立的 TerminalPanel(独立 shell、独立历史) - 标签栏左侧 ➕ 按钮弹主机选择下拉 - 标签可关闭(×),关闭时如有活跃 shell 弹确认 - 标签双击重命名 - 标签标题显示:主机别名(连接状态点 + 别名) - 兼容老 API:attach / close_shell / _set_status(操作当前激活标签) - 新 API:open_terminal(host_id, conn, label) / close_current_tab() / current_host_id(property) """ import sys from typing import Optional, List from PyQt5.QtCore import Qt, pyqtSignal, QPoint from PyQt5.QtGui import QFont, QIcon from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QPushButton, QLabel, QMenu, QInputDialog, QMessageBox, QToolButton, QApplication, QListWidget, QListWidgetItem, QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QComboBox, ) from core.ssh_client import SSHConnection from core.manager import ConnectionManager from .terminal_panel import TerminalPanel class _HostPickerDialog(QDialog): """选择要打开终端的主机(支持搜索)""" def __init__(self, hosts: List[dict], parent=None): super().__init__(parent) self.setWindowTitle("选择主机") self.resize(380, 400) self.hosts = hosts self.selected_host_id: Optional[str] = None self._build() def _build(self): v = QVBoxLayout(self) self.search = QLineEdit() self.search.setPlaceholderText("🔍 搜索主机名/地址/用户名...") self.search.textChanged.connect(self._refresh) v.addWidget(self.search) self.listw = QListWidget() self.listw.itemDoubleClicked.connect(self._on_double_clicked) v.addWidget(self.listw, 1) bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(self._on_ok) bb.rejected.connect(self.reject) v.addWidget(bb) self._refresh() self.search.setFocus() def _refresh(self): q = self.search.text().strip().lower() self.listw.clear() for h in self.hosts: hay = f"{h.get('name','')} {h.get('host','')} {h.get('username','')}".lower() if q and q not in hay: continue display = f"{h.get('name', h.get('host'))} · {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}" item = QListWidgetItem(display) item.setData(Qt.UserRole, h.get("id")) self.listw.addItem(item) if self.listw.count() > 0: self.listw.setCurrentRow(0) def _on_double_clicked(self, _item): self._on_ok() def _on_ok(self): item = self.listw.currentItem() if not item: QMessageBox.information(self, "提示", "请选择一台主机") return self.selected_host_id = item.data(Qt.UserRole) self.accept() class TerminalTabWidget(QWidget): """多标签终端容器""" # 标签关闭时通知主窗口 terminal_tab_closed = pyqtSignal() # 当前激活标签变化 current_terminal_changed = pyqtSignal(str) # host_id def __init__(self, manager: ConnectionManager, parent=None): super().__init__(parent) self.manager = manager self._tabs_by_host: dict = {} # host_id -> (tab_index, TerminalPanel) self._build() def _build(self): v = QVBoxLayout(self) v.setContentsMargins(0, 0, 0, 0) v.setSpacing(0) self.tabs = QTabWidget() self.tabs.setTabsClosable(True) self.tabs.setMovable(True) self.tabs.setDocumentMode(True) # 标签栏左侧按钮:新建 + 下拉 self.btn_new = QToolButton() self.btn_new.setText("➕") self.btn_new.setToolTip("新建终端标签(选择主机)") self.btn_new.setPopupMode(QToolButton.InstantPopup) self.btn_new.setFixedWidth(28) # 用菜单代替 popup new_menu = QMenu(self.btn_new) new_menu.addAction("📡 新建终端(选择主机)", self._on_new_from_picker) new_menu.addSeparator() new_menu.addAction("➕ 新建空白标签", self._on_new_blank) self.btn_new.setMenu(new_menu) # 加到 tab 栏最左 self.tabs.setCornerWidget(self.btn_new, Qt.TopLeftCorner) # 标签页右键菜单 self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu) self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu) # 关闭按钮 self.tabs.tabCloseRequested.connect(self._on_close_requested) # 切换标签 self.tabs.currentChanged.connect(self._on_current_changed) # 双击标签重命名 self.tabs.tabBar().tabBarDoubleClicked.connect(self._on_tab_double_clicked) v.addWidget(self.tabs, 1) # 初始空白页 self._add_placeholder() # ============================================================ # 标签管理 # ============================================================ def _add_placeholder(self): """初始占位页(提示用户点 ➕ 新建)""" w = QWidget() layout = QVBoxLayout(w) layout.setAlignment(Qt.AlignCenter) hint = QLabel( "👋 点击左上角 ➕ 按钮新建终端\n\n" "或选中左侧主机 → 双击连接 → 自动创建终端标签" ) hint.setAlignment(Qt.AlignCenter) hint.setStyleSheet("color: #888; font-size: 12pt;") layout.addWidget(hint) idx = self.tabs.addTab(w, "📡 欢迎") self.tabs.setTabToolTip(idx, "新建终端开始使用") # 占位页不可关闭 from PyQt5.QtWidgets import QTabBar self.tabs.tabBar().setTabButton(idx, QTabBar.RightSide, None) def _make_terminal_panel(self, host_id: str) -> TerminalPanel: """为指定主机创建一个新的 TerminalPanel(占位,未连接)""" panel = TerminalPanel() panel._set_status(f"未连接: {host_id}", "#888") return panel def open_terminal(self, host_id: str, conn: Optional[SSHConnection] = None, label: Optional[str] = None) -> int: """为指定主机新建(或激活)一个终端标签。 同一主机已有标签则激活并复用(按需求 B:同主机可多开 → 这里改为总是新建) 返回新建标签的 index。 """ # 需求是"同主机可多开"——所以总是新建 host_info = self.manager.get_host(host_id) if host_id else None title = label or (host_info.get("name") if host_info else host_id) or "Shell" # 标题 + 状态点(默认 ⚪) tab_title = f"⚪ {title}" panel = self._make_terminal_panel(host_id or "") idx = self.tabs.addTab(panel, tab_title) self.tabs.setTabToolTip(idx, f"主机: {host_id}") # 记录映射 self._tabs_by_host.setdefault(host_id or f"__adhoc_{idx}", []).append((idx, panel)) self.tabs.setCurrentIndex(idx) if conn: panel.attach(conn) self._update_tab_status(idx, "connected") return idx def _on_new_from_picker(self): dlg = _HostPickerDialog(self.manager.list_hosts(), self) if dlg.exec_() != dlg.Accepted: return host_id = dlg.selected_host_id if not host_id: return conn = self.manager.get_connection(host_id) if not conn or not conn.connected: # 没连接:先建占位标签,用户自己去连接 self.open_terminal(host_id, conn=None) QMessageBox.information( self, "提示", f"已为主机「{self.manager.get_host(host_id).get('name', host_id)}」创建终端标签。\n" "请在左侧主机列表点 🔌 连接。" ) else: self.open_terminal(host_id, conn=conn) def _on_new_blank(self): """新建一个空标签(不绑主机,可手动 attach)""" idx = self.tabs.addTab(self._make_terminal_panel(""), "⚪ Shell") self.tabs.setCurrentIndex(idx) def _on_close_requested(self, idx: int): self._close_tab(idx) def _on_current_changed(self, idx: int): if idx < 0: self.current_terminal_changed.emit("") return panel = self._panel_at(idx) if panel: hid = self._host_id_for_panel(panel) self.current_terminal_changed.emit(hid) def _on_tab_double_clicked(self, idx: int): if idx < 0 or idx >= self.tabs.count(): return cur = self.tabs.tabText(idx) new, ok = QInputDialog.getText(self, "重命名标签", "标签名:", text=cur) if ok and new.strip(): self.tabs.setTabText(idx, new.strip()) def _on_tab_context_menu(self, pos: QPoint): idx = self.tabs.tabBar().tabAt(pos) if idx < 0: return menu = QMenu(self) panel = self._panel_at(idx) a_rename = menu.addAction("重命名") a_close = menu.addAction("关闭标签") a_close_others = menu.addAction("关闭其他") if panel and panel._connected: menu.addSeparator() menu.addAction(f"已连接到 {panel.conn.username}@{panel.conn.host}") a_reconnect = menu.addAction("重新打开 shell") else: a_reconnect = None chosen = menu.exec_(self.tabs.tabBar().mapToGlobal(pos)) if chosen == a_rename: self._on_tab_double_clicked(idx) elif chosen == a_close: self._close_tab(idx) elif chosen == a_close_others: self._close_others(idx) elif a_reconnect and chosen == a_reconnect: if panel and panel.conn: panel._reopen_shell() def _close_tab(self, idx: int): panel = self._panel_at(idx) if panel and panel._connected: host = panel.conn.host if panel.conn else "?" reply = QMessageBox.question( self, "关闭终端", f"关闭终端「{self.tabs.tabText(idx)}」?\n" f"将断开 {panel.conn.username}@{host} 的 shell。", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return if panel: panel.close_shell() # 移除映射 for host_id, items in list(self._tabs_by_host.items()): self._tabs_by_host[host_id] = [(i, p) for (i, p) in items if i != idx] self.tabs.removeTab(idx) self.terminal_tab_closed.emit() if self.tabs.count() == 0: self._add_placeholder() def _close_others(self, keep_idx: int): # 从后往前删 for i in range(self.tabs.count() - 1, -1, -1): if i != keep_idx: self._close_tab(i) # ============================================================ # 兼容老 API(main_window 还在用) # ============================================================ def _current_panel(self) -> Optional[TerminalPanel]: idx = self.tabs.currentIndex() if idx < 0: return None w = self.tabs.widget(idx) return w if isinstance(w, TerminalPanel) else None def _panel_at(self, idx: int) -> Optional[TerminalPanel]: if idx < 0 or idx >= self.tabs.count(): return None w = self.tabs.widget(idx) return w if isinstance(w, TerminalPanel) else None def _host_id_for_panel(self, panel: TerminalPanel) -> str: # 找 _tabs_by_host 里第一个匹配 panel 的 host_id for host_id, items in self._tabs_by_host.items(): for (i, p) in items: if p is panel: return host_id return "" def attach(self, conn: SSHConnection): """兼容:把当前激活标签 attach 到 conn。 如果当前标签不是 TerminalPanel 或没绑 host,新建一个标签。 """ cur = self._current_panel() if cur is None: # 当前是占位页 → 直接新建 host_id = self._find_host_id_by_conn(conn) idx = self.open_terminal(host_id or "Shell", conn=conn) else: cur.attach(conn) host_id = self._find_host_id_by_conn(conn) self._update_tab_status_by_panel(cur, "connected") self.tabs.setCurrentIndex(0) def close_shell(self): cur = self._current_panel() if cur: cur.close_shell() self._update_tab_status_by_panel(cur, "disconnected") def _set_status(self, text: str, color: str = "#888"): cur = self._current_panel() if cur: cur._set_status(text, color) @property def current_host_id(self) -> str: cur = self._current_panel() return self._host_id_for_panel(cur) if cur else "" def _find_host_id_by_conn(self, conn: SSHConnection) -> str: for h in self.manager.list_hosts(): c = self.manager.get_connection(h.get("id")) if c is conn: return h.get("id") return "" def _update_tab_status(self, idx: int, state: str): """更新标签标题:⚪未连接 / 🟢已连接 / 🔴已断开""" title = self.tabs.tabText(idx) # 去掉已有状态点 for prefix in ("⚪ ", "🟢 ", "🔴 ", "🟡 "): if title.startswith(prefix): title = title[len(prefix):] break if state == "connected": new_title = f"🟢 {title}" elif state == "disconnected": new_title = f"🔴 {title}" elif state == "error": new_title = f"🟡 {title}" else: new_title = f"⚪ {title}" self.tabs.setTabText(idx, new_title) def _update_tab_status_by_panel(self, panel: TerminalPanel, state: str): for i in range(self.tabs.count()): if self.tabs.widget(i) is panel: self._update_tab_status(i, state) return def shutdown(self): """主窗口关闭时:关闭所有标签的 shell""" for i in range(self.tabs.count()): w = self.tabs.widget(i) if isinstance(w, TerminalPanel): w.close_shell()