diff --git a/test_ui.py b/test_ui.py index b79c5ae..a50d691 100644 --- a/test_ui.py +++ b/test_ui.py @@ -40,12 +40,15 @@ def main(): # 验证新增功能 assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图" assert hasattr(w.monitor, "_spark_mem"), "MonitorPanel 应有内存迷你图" - assert hasattr(w.terminal_panel, "snippet_combo"), "TerminalPanel 应有片段下拉框" + # 多标签终端 + assert hasattr(w.terminal_panel, "tabs"), "TerminalTabWidget 应有 tabs" + assert hasattr(w.terminal_panel, "open_terminal"), "应有 open_terminal 方法" + assert w.terminal_panel.tabs.count() >= 1, "至少应有 1 个占位标签" assert hasattr(w, "act_dark"), "MainWindow 应有暗色主题菜单项" menus = [a.text() for a in w.menuBar().actions()] assert "视图(&V)" in menus, f"应有视图菜单: {menus}" print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位") - print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框") + print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 多标签终端") print("[3/4] 验证对话框") # 主机对话框 diff --git a/ui/main_window.py b/ui/main_window.py index f396475..f496ec6 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -23,6 +23,7 @@ from .workers import ConnectWorker from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .config_dialog import AIConfigDialog from .terminal_panel import TerminalPanel +from .terminal_tab_widget import TerminalTabWidget from .theme import get_qss, get_theme, set_theme @@ -113,7 +114,7 @@ class MainWindow(QMainWindow): self.tabs.setDocumentMode(True) # Tab1: 终端 - self.terminal_panel = TerminalPanel() + self.terminal_panel = TerminalTabWidget(self.manager) self.tabs.addTab(self.terminal_panel, "⌨ 终端") # Tab2: 文件浏览 @@ -215,19 +216,18 @@ class MainWindow(QMainWindow): return host_id = data self.current_host_id = host_id - # 切换主机时关闭旧 shell - self.terminal_panel.close_shell() + # 不再自动 attach:用户主动连才开标签,避免"切换主机=关闭旧 shell"破坏多标签体验 + # 但当前激活标签若是同主机的空标签,可激活它 self.file_browser.set_host(host_id) self.monitor.set_host(host_id) self.ai_panel.set_host(host_id) self._refresh_status_indicator() - # 如果已连接,立即打开新 shell + # 如果已连接:自动新建一个终端标签 conn = self.manager.get_connection(host_id) if conn and conn.connected: - self.terminal_panel.attach(conn) + self.terminal_panel.open_terminal(host_id, conn=conn) self.tabs.setCurrentIndex(0) - else: - self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828") + # 否则什么都不做(保持现有标签不被打扰) def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int): """双击主机节点 = 连接;双击分组节点 = 展开/折叠""" @@ -408,10 +408,10 @@ class MainWindow(QMainWindow): # 同步到 UI self.file_browser.set_host(host_id) self.monitor.set_host(host_id) - # 打开 shell + # 打开新终端标签 conn = self.manager.get_connection(host_id) if conn: - self.terminal_panel.attach(conn) + self.terminal_panel.open_terminal(host_id, conn=conn) self.tabs.setCurrentIndex(0) # 切到终端 Tab else: QMessageBox.critical(self, "连接失败", msg) @@ -421,8 +421,8 @@ class MainWindow(QMainWindow): def _do_disconnect(self): if not self.current_host_id: return + # 只关闭当前激活标签的 shell,其他标签不受影响 self.terminal_panel.close_shell() - self.terminal_panel._set_status("已断开", "#888") self.manager.disconnect(self.current_host_id) self.statusBar().showMessage("已断开", 3000) self._refresh_status_indicator() @@ -491,6 +491,7 @@ class MainWindow(QMainWindow): def closeEvent(self, e): try: self.monitor._stop_worker() + self.terminal_panel.shutdown() self.manager.close_all() except Exception: pass diff --git a/ui/terminal_tab_widget.py b/ui/terminal_tab_widget.py new file mode 100644 index 0000000..e054b4f --- /dev/null +++ b/ui/terminal_tab_widget.py @@ -0,0 +1,372 @@ +""" +多标签终端容器 +- 每个标签是一个独立的 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()