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
+11 -10
View File
@@ -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