Files
sshclient/ui/main_window.py
T
Hermes 2c6f06e392 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.
2026-07-29 07:05:59 +08:00

499 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主窗口
- 左侧:主机列表 + 操作
- 右侧:标签页(终端、文件浏览、监控、AI Agent)
"""
import sys
import time
from typing import Optional
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget,
QGroupBox, QFormLayout, QMessageBox, QStatusBar, QAction,
QFileDialog, QInputDialog, QToolBar, QApplication, QStyle,
QTreeWidget, QTreeWidgetItem, QMenu,
)
from core.manager import ConnectionManager
from core.ai_agent import AIAgent
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
APP_NAME = "SSHClient"
APP_VERSION = "1.0.0"
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} - AI 增强 SSH 客户端")
self.resize(1280, 800)
self.manager = ConnectionManager()
self.agent = AIAgent()
self.current_host_id: Optional[str] = None
self.connect_worker: Optional[ConnectWorker] = None
self._build_ui()
self._build_menu()
self._build_statusbar()
self._apply_theme()
self._load_hosts_to_list()
# ============================================================
# UI 构建
# ============================================================
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(6, 6, 6, 6)
root.setSpacing(6)
splitter = QSplitter(Qt.Horizontal)
root.addWidget(splitter)
# ====== 左侧:主机面板 ======
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(4, 4, 4, 4)
lv.setSpacing(6)
host_title = QLabel("🖥 主机")
host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;")
lv.addWidget(host_title)
self.host_tree = QTreeWidget()
self.host_tree.setHeaderHidden(True)
self.host_tree.setIndentation(16)
self.host_tree.setAnimated(True)
self.host_tree.setExpandsOnDoubleClick(False)
self.host_tree.itemSelectionChanged.connect(self._on_host_selected)
self.host_tree.itemDoubleClicked.connect(self._on_host_double_clicked)
# 右键菜单
self.host_tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.host_tree.customContextMenuRequested.connect(self._on_host_context_menu)
lv.addWidget(self.host_tree, 1)
# 主机操作按钮
btn_grid = QVBoxLayout()
btn_grid.setSpacing(4)
self.btn_add = QPushButton(" 新增主机")
self.btn_add.clicked.connect(self._add_host)
self.btn_edit = QPushButton("✏ 编辑")
self.btn_edit.clicked.connect(self._edit_host)
self.btn_delete = QPushButton("🗑 删除")
self.btn_delete.clicked.connect(self._delete_host)
self.btn_connect = QPushButton("🔌 连接")
self.btn_connect.clicked.connect(self._do_connect)
self.btn_disconnect = QPushButton("⛔ 断开")
self.btn_disconnect.clicked.connect(self._do_disconnect)
for b in (self.btn_add, self.btn_edit, self.btn_delete,
self.btn_connect, self.btn_disconnect):
btn_grid.addWidget(b)
lv.addLayout(btn_grid)
# 连接状态
self.conn_status_label = QLabel("未选择")
self.conn_status_label.setStyleSheet("color: #666; padding: 4px;")
lv.addWidget(self.conn_status_label)
splitter.addWidget(left)
# ====== 右侧:Tab 区 ======
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
# Tab1: 终端
self.terminal_panel = TerminalTabWidget(self.manager)
self.tabs.addTab(self.terminal_panel, "⌨ 终端")
# Tab2: 文件浏览
self.file_browser = FileBrowser(self.manager)
self.tabs.addTab(self.file_browser, "📁 文件")
# Tab3: 监控
self.monitor = MonitorPanel(self.manager)
self.tabs.addTab(self.monitor, "📊 监控")
# Tab4: AI Agent
self.ai_panel = AIChatPanel(self.manager, self.agent)
self.tabs.addTab(self.ai_panel, "🤖 AI Agent")
splitter.addWidget(self.tabs)
splitter.setSizes([280, 1000])
def _build_menu(self):
menubar = self.menuBar()
# 文件
m_file = menubar.addMenu("文件(&F)")
act_export = QAction("导出主机配置", self)
act_export.triggered.connect(self._export_hosts)
m_file.addAction(act_export)
act_import = QAction("导入主机配置", self)
act_import.triggered.connect(self._import_hosts)
m_file.addAction(act_import)
m_file.addSeparator()
act_exit = QAction("退出", self)
act_exit.setShortcut("Ctrl+Q")
act_exit.triggered.connect(self.close)
m_file.addAction(act_exit)
# AI
m_ai = menubar.addMenu("AI(&A)")
act_ai = QAction("⚙ AI 设置...", self)
act_ai.triggered.connect(self._show_ai_config)
m_ai.addAction(act_ai)
act_clear = QAction("清空 AI 对话", self)
act_clear.triggered.connect(lambda: self.ai_panel._clear())
m_ai.addAction(act_clear)
# 视图
m_view = menubar.addMenu("视图(&V)")
self.act_dark = QAction("🌙 暗色主题", self, checkable=True)
self.act_dark.setChecked(get_theme() == "dark")
self.act_dark.triggered.connect(self._toggle_theme)
m_view.addAction(self.act_dark)
# 帮助
m_help = menubar.addMenu("帮助(&H)")
act_about = QAction("关于", self)
act_about.triggered.connect(self._about)
m_help.addAction(act_about)
def _build_statusbar(self):
self.statusBar().showMessage(f"{APP_NAME} v{APP_VERSION} 就绪")
# ============================================================
# 主机列表(树形 + 分组)
# ============================================================
def _load_hosts_to_list(self):
self.host_tree.clear()
by_group = self.manager.list_hosts_by_group()
# 按分组顺序添加
for gname in self.manager.list_groups():
hosts = by_group.get(gname, [])
self._add_group_item(gname, hosts)
# 未分组
ungrouped = by_group.get("", [])
self._add_group_item("", ungrouped)
# 默认展开所有分组
self.host_tree.expandAll()
self._refresh_status_indicator()
def _add_group_item(self, group_name: str, hosts: list):
"""添加一个分组节点 + 其下主机"""
label = group_name if group_name else "未分组"
icon = "📂" if group_name else "📭"
group_item = QTreeWidgetItem([f"{icon} {label} ({len(hosts)})"])
group_item.setFont(0, QFont("sans-serif", 9, QFont.Bold))
group_item.setData(0, Qt.UserRole, "__group__")
group_item.setData(0, Qt.UserRole + 1, group_name)
self.host_tree.addTopLevelItem(group_item)
for h in hosts:
name = h.get("name", h.get("host", "?"))
info = f"🖥 {name}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}"
item = QTreeWidgetItem([info])
item.setData(0, Qt.UserRole, h.get("id"))
group_item.addChild(item)
def _on_host_selected(self):
"""选中主机时切换到对应主机;选中分组不做任何事"""
items = self.host_tree.selectedItems()
if not items:
self.current_host_id = None
return
item = items[0]
data = item.data(0, Qt.UserRole)
if data == "__group__" or not data:
# 选中了分组节点,不切换主机
return
host_id = data
self.current_host_id = host_id
# 不再自动 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()
# 如果已连接:自动新建一个终端标签
conn = self.manager.get_connection(host_id)
if conn and conn.connected:
self.terminal_panel.open_terminal(host_id, conn=conn)
self.tabs.setCurrentIndex(0)
# 否则什么都不做(保持现有标签不被打扰)
def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int):
"""双击主机节点 = 连接;双击分组节点 = 展开/折叠"""
data = item.data(0, Qt.UserRole)
if data == "__group__":
item.setExpanded(not item.isExpanded())
return
if data:
self._do_connect()
def _on_host_context_menu(self, pos):
"""右键菜单:分组操作 + 主机操作"""
item = self.host_tree.itemAt(pos)
menu = QMenu(self)
# 空白处:新建分组
if item is None:
menu.addAction("📁 新建分组", self._add_group_dialog)
menu.exec_(self.host_tree.viewport().mapToGlobal(pos))
return
data = item.data(0, Qt.UserRole)
if data == "__group__":
group_name = item.data(0, Qt.UserRole + 1)
menu.addAction("📁 新建分组", self._add_group_dialog)
if group_name: # 非未分组
menu.addAction("✏ 重命名分组", lambda: self._rename_group_dialog(group_name))
menu.addAction("🗑 删除分组", lambda: self._delete_group_dialog(group_name))
menu.exec_(self.host_tree.viewport().mapToGlobal(pos))
else:
# 主机节点
host_id = data
host = self.manager.get_host(host_id)
if not host:
return
menu.addAction("🔌 连接", self._do_connect)
menu.addAction("✏ 编辑", self._edit_host)
menu.addAction("🗑 删除", self._delete_host)
menu.addSeparator()
# 移动到分组
move_menu = menu.addMenu("📦 移动到分组")
current_group = host.get("group", "")
for gname in self.manager.list_groups():
if gname != current_group:
move_menu.addAction(f"📁 {gname}", lambda g=gname: self._move_host(host_id, g))
move_menu.addAction("📭 未分组", lambda: self._move_host(host_id, ""))
move_menu.addAction(" 新建分组并移入...", lambda: self._new_group_and_move(host_id))
menu.exec_(self.host_tree.viewport().mapToGlobal(pos))
# ============================================================
# 分组操作
# ============================================================
def _add_group_dialog(self):
name, ok = QInputDialog.getText(self, "新建分组", "分组名称:")
if ok and name.strip():
if self.manager.add_group(name):
self._load_hosts_to_list()
self.statusBar().showMessage(f"已新建分组「{name.strip()}", 3000)
else:
QMessageBox.warning(self, "提示", "分组名称为空或已存在")
def _rename_group_dialog(self, old_name: str):
new_name, ok = QInputDialog.getText(self, "重命名分组", "新名称:", text=old_name)
if ok and new_name.strip() and new_name.strip() != old_name:
if self.manager.rename_group(old_name, new_name):
self._load_hosts_to_list()
self.statusBar().showMessage(f"已重命名: {old_name}{new_name.strip()}", 3000)
else:
QMessageBox.warning(self, "提示", "名称为空或已存在")
def _delete_group_dialog(self, group_name: str):
hosts = [h for h in self.manager.list_hosts() if h.get("group") == group_name]
msg = f"删除分组「{group_name}」?"
if hosts:
msg += f"\n组内 {len(hosts)} 台主机将移到「未分组」。"
if QMessageBox.question(self, "确认", msg, QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes:
self.manager.remove_group(group_name)
self._load_hosts_to_list()
self.statusBar().showMessage(f"已删除分组「{group_name}", 3000)
def _move_host(self, host_id: str, group_name: str):
self.manager.move_host_to_group(host_id, group_name)
self._load_hosts_to_list()
label = group_name if group_name else "未分组"
self.statusBar().showMessage(f"已移动到「{label}", 3000)
def _new_group_and_move(self, host_id: str):
name, ok = QInputDialog.getText(self, "新建分组并移入", "分组名称:")
if ok and name.strip():
if self.manager.add_group(name):
self.manager.move_host_to_group(host_id, name.strip())
self._load_hosts_to_list()
self.statusBar().showMessage(f"已新建分组并移入「{name.strip()}", 3000)
else:
QMessageBox.warning(self, "提示", "分组名称为空或已存在")
def _refresh_status_indicator(self):
if not self.current_host_id:
self.conn_status_label.setText("未选择主机")
return
c = self.manager.get_connection(self.current_host_id)
if c and c.connected:
self.conn_status_label.setText(f"🟢 已连接: {c.username}@{c.host}:{c.port}")
self.conn_status_label.setStyleSheet("color: #2e7d32; padding: 4px;")
else:
self.conn_status_label.setText("🔴 未连接")
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
def terminal_input_set_enabled(self, enabled: bool):
self.cmd_input.setEnabled(enabled)
self.btn_run.setEnabled(enabled)
# ============================================================
# 主机 CRUD
# ============================================================
def _add_host(self):
info = self._prompt_host_info()
if info is None:
return
self.manager.add_host(info)
self._load_hosts_to_list()
self.statusBar().showMessage("已新增主机", 3000)
def _edit_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
info = self._prompt_host_info(h)
if info is None:
return
self.manager.update_host(self.current_host_id, info)
self._load_hosts_to_list()
self.statusBar().showMessage("已更新主机", 3000)
def _delete_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
if QMessageBox.question(
self, "确认删除",
f"确定删除主机「{h.get('name', h.get('host'))}」?",
QMessageBox.Yes | QMessageBox.No
) != QMessageBox.Yes:
return
self.manager.remove_host(self.current_host_id)
self._load_hosts_to_list()
self.statusBar().showMessage("已删除", 3000)
def _prompt_host_info(self, current: Optional[dict] = None):
from .host_dialog import HostDialog
dlg = HostDialog(self, current)
if dlg.exec_() == dlg.Accepted:
return dlg.get_value()
return None
# ============================================================
# 连接
# ============================================================
def _do_connect(self):
if not self.current_host_id:
QMessageBox.information(self, "提示", "请先选择一台主机")
return
if self.connect_worker and self.connect_worker.isRunning():
return
h = self.manager.get_host(self.current_host_id)
self.statusBar().showMessage(f"正在连接 {h.get('host')}...")
self.btn_connect.setEnabled(False)
self.connect_worker = ConnectWorker(self.manager, self.current_host_id)
self.connect_worker.finished_with.connect(self._on_connect_done)
self.connect_worker.start()
def _on_connect_done(self, host_id: str, ok: bool, msg: str):
self.btn_connect.setEnabled(True)
if ok:
self.statusBar().showMessage(msg, 5000)
# 同步到 UI
self.file_browser.set_host(host_id)
self.monitor.set_host(host_id)
# 打开新终端标签
conn = self.manager.get_connection(host_id)
if conn:
self.terminal_panel.open_terminal(host_id, conn=conn)
self.tabs.setCurrentIndex(0) # 切到终端 Tab
else:
QMessageBox.critical(self, "连接失败", msg)
self.statusBar().showMessage(f"连接失败: {msg}", 5000)
self._refresh_status_indicator()
def _do_disconnect(self):
if not self.current_host_id:
return
# 只关闭当前激活标签的 shell,其他标签不受影响
self.terminal_panel.close_shell()
self.manager.disconnect(self.current_host_id)
self.statusBar().showMessage("已断开", 3000)
self._refresh_status_indicator()
# ============================================================
# AI / 关于
# ============================================================
def _apply_theme(self):
theme = get_theme()
qss = get_qss(theme)
QApplication.instance().setStyleSheet(qss)
def _toggle_theme(self):
new_theme = "dark" if self.act_dark.isChecked() else "light"
set_theme(new_theme)
self._apply_theme()
label = "暗色" if new_theme == "dark" else "亮色"
self.statusBar().showMessage(f"已切换到{label}主题", 3000)
def _show_ai_config(self):
dlg = AIConfigDialog(self.agent, self)
if dlg.exec_():
self.statusBar().showMessage("AI 配置已更新", 3000)
def _about(self):
QMessageBox.about(
self, "关于",
f"<h3>{APP_NAME} v{APP_VERSION}</h3>"
"<p>基于 PyQt5 + paramiko 的 Windows SSH 客户端</p>"
"<ul>"
"<li>多主机管理与连接</li>"
"<li>远程终端</li>"
"<li>SFTP 文件浏览 / 上传 / 下载</li>"
"<li>CPU / 内存 / 磁盘 / 网络 实时监控</li>"
"<li>AI AgentOpenAI 兼容 API,支持工具调用)</li>"
"</ul>"
"<p style='color:#888;'>本程序使用 paramiko、PyQt5、psutil、requests 等开源库。</p>"
)
def _export_hosts(self):
import json
path, _ = QFileDialog.getSaveFileName(self, "导出主机配置", "hosts.json", "JSON (*.json)")
if not path:
return
with open(path, "w", encoding="utf-8") as f:
json.dump(self.manager.list_hosts(), f, ensure_ascii=False, indent=2)
self.statusBar().showMessage(f"已导出到 {path}", 5000)
def _import_hosts(self):
import json
path, _ = QFileDialog.getOpenFileName(self, "导入主机配置", "", "JSON (*.json)")
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
hosts = json.load(f)
for h in hosts:
if "id" in h:
del h["id"]
self.manager.add_host(h)
self._load_hosts_to_list()
self.statusBar().showMessage(f"已导入 {len(hosts)} 台主机", 5000)
except Exception as e:
QMessageBox.critical(self, "导入失败", str(e))
def closeEvent(self, e):
try:
self.monitor._stop_worker()
self.terminal_panel.shutdown()
self.manager.close_all()
except Exception:
pass
super().closeEvent(e)