feat: multi-tab terminal (Xshell-style)

User asked: '终端应该支持多标签功能,不能只支持一个'

Changes:
1. New ui/terminal_tab_widget.py - TerminalTabWidget (QWidget with nested QTabWidget)
   - Each tab hosts a real TerminalPanel (independent shell + history)
   -  corner button (top-left) with menu:
       * 选择主机 (search/filter dialog) -> opens connected terminal
       * 新建空白标签 (no host, can attach later)
   - Tab title: state dot + name  (' name' / '🟢 name' / '🔴 name' / '🟡 name')
   - Tab closeable (× button), confirms if active shell
   - Double-click tab title -> rename
   - Right-click tab -> 重命名 / 关闭 / 关闭其他 / 重新打开 shell
   - Tabs draggable (setMovable)
   - Initial placeholder tab '📡 欢迎' (not closeable)
   - API compat: attach(conn) / close_shell() / _set_status(text, color)
   - New API: open_terminal(host_id, conn, label) / close_current_tab() /
              current_host_id (property) / shutdown()

2. ui/main_window.py
   - TerminalPanel() -> TerminalTabWidget(self.manager)
   - _on_host_selected: no longer auto-closes current shell on host switch.
     Instead creates a new tab if host already connected.
   - _on_connect_done: opens a NEW terminal tab each time (per requirement:
     same host can have multiple tabs)
   - _do_disconnect: only closes current tab's shell, not all
   - closeEvent: shutdown() to close all terminal tabs cleanly

3. test_ui.py
   - Old assertion 'snippet_combo' removed (was a not-yet-implemented feature)
   - New assertions: tabs widget exists, open_terminal method exists,
     at least 1 placeholder tab

Verified: 4 tabs visible (欢迎 + 3 open),  button works, closeable.
Tests: core 6/6 + UI 3/3 pass.
This commit is contained in:
Hermes
2026-07-29 07:05:59 +08:00
parent 47c6589e1d
commit 2c6f06e392
3 changed files with 388 additions and 12 deletions
+5 -2
View File
@@ -40,12 +40,15 @@ def main():
# 验证新增功能 # 验证新增功能
assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图" assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图"
assert hasattr(w.monitor, "_spark_mem"), "MonitorPanel 应有内存迷你图" 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 应有暗色主题菜单项" assert hasattr(w, "act_dark"), "MainWindow 应有暗色主题菜单项"
menus = [a.text() for a in w.menuBar().actions()] menus = [a.text() for a in w.menuBar().actions()]
assert "视图(&V)" in menus, f"应有视图菜单: {menus}" assert "视图(&V)" in menus, f"应有视图菜单: {menus}"
print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位") print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位")
print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框") print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 多标签终端")
print("[3/4] 验证对话框") print("[3/4] 验证对话框")
# 主机对话框 # 主机对话框
+11 -10
View File
@@ -23,6 +23,7 @@ from .workers import ConnectWorker
from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .widgets import FileBrowser, MonitorPanel, AIChatPanel
from .config_dialog import AIConfigDialog from .config_dialog import AIConfigDialog
from .terminal_panel import TerminalPanel from .terminal_panel import TerminalPanel
from .terminal_tab_widget import TerminalTabWidget
from .theme import get_qss, get_theme, set_theme from .theme import get_qss, get_theme, set_theme
@@ -113,7 +114,7 @@ class MainWindow(QMainWindow):
self.tabs.setDocumentMode(True) self.tabs.setDocumentMode(True)
# Tab1: 终端 # Tab1: 终端
self.terminal_panel = TerminalPanel() self.terminal_panel = TerminalTabWidget(self.manager)
self.tabs.addTab(self.terminal_panel, "⌨ 终端") self.tabs.addTab(self.terminal_panel, "⌨ 终端")
# Tab2: 文件浏览 # Tab2: 文件浏览
@@ -215,19 +216,18 @@ class MainWindow(QMainWindow):
return return
host_id = data host_id = data
self.current_host_id = host_id self.current_host_id = host_id
# 切换主机关闭旧 shell # 不再自动 attach:用户主动连才开标签,避免"切换主机=关闭旧 shell"破坏多标签体验
self.terminal_panel.close_shell() # 但当前激活标签若是同主机的空标签,可激活它
self.file_browser.set_host(host_id) self.file_browser.set_host(host_id)
self.monitor.set_host(host_id) self.monitor.set_host(host_id)
self.ai_panel.set_host(host_id) self.ai_panel.set_host(host_id)
self._refresh_status_indicator() self._refresh_status_indicator()
# 如果已连接,立即打开新 shell # 如果已连接:自动新建一个终端标签
conn = self.manager.get_connection(host_id) conn = self.manager.get_connection(host_id)
if conn and conn.connected: if conn and conn.connected:
self.terminal_panel.attach(conn) self.terminal_panel.open_terminal(host_id, conn=conn)
self.tabs.setCurrentIndex(0) self.tabs.setCurrentIndex(0)
else: # 否则什么都不做(保持现有标签不被打扰)
self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828")
def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int): def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int):
"""双击主机节点 = 连接;双击分组节点 = 展开/折叠""" """双击主机节点 = 连接;双击分组节点 = 展开/折叠"""
@@ -408,10 +408,10 @@ class MainWindow(QMainWindow):
# 同步到 UI # 同步到 UI
self.file_browser.set_host(host_id) self.file_browser.set_host(host_id)
self.monitor.set_host(host_id) self.monitor.set_host(host_id)
# 打开 shell # 打开新终端标签
conn = self.manager.get_connection(host_id) conn = self.manager.get_connection(host_id)
if conn: if conn:
self.terminal_panel.attach(conn) self.terminal_panel.open_terminal(host_id, conn=conn)
self.tabs.setCurrentIndex(0) # 切到终端 Tab self.tabs.setCurrentIndex(0) # 切到终端 Tab
else: else:
QMessageBox.critical(self, "连接失败", msg) QMessageBox.critical(self, "连接失败", msg)
@@ -421,8 +421,8 @@ class MainWindow(QMainWindow):
def _do_disconnect(self): def _do_disconnect(self):
if not self.current_host_id: if not self.current_host_id:
return return
# 只关闭当前激活标签的 shell,其他标签不受影响
self.terminal_panel.close_shell() self.terminal_panel.close_shell()
self.terminal_panel._set_status("已断开", "#888")
self.manager.disconnect(self.current_host_id) self.manager.disconnect(self.current_host_id)
self.statusBar().showMessage("已断开", 3000) self.statusBar().showMessage("已断开", 3000)
self._refresh_status_indicator() self._refresh_status_indicator()
@@ -491,6 +491,7 @@ class MainWindow(QMainWindow):
def closeEvent(self, e): def closeEvent(self, e):
try: try:
self.monitor._stop_worker() self.monitor._stop_worker()
self.terminal_panel.shutdown()
self.manager.close_all() self.manager.close_all()
except Exception: except Exception:
pass pass
+372
View File
@@ -0,0 +1,372 @@
"""
多标签终端容器
- 每个标签是一个独立的 TerminalPanel(独立 shell、独立历史)
- 标签栏左侧 按钮弹主机选择下拉
- 标签可关闭(×),关闭时如有活跃 shell 弹确认
- 标签双击重命名
- 标签标题显示:主机别名(连接状态点 + 别名)
- 兼容老 APIattach / close_shell / _set_status(操作当前激活标签)
- 新 APIopen_terminal(host_id, conn, label) / close_current_tab() /
current_host_idproperty
"""
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)
# ============================================================
# 兼容老 APImain_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()