c4dd4d8adf
Two independent root causes for '左右能拉,上下不能拉': 1. Event delivery: the top 6px edge band belongs to QMenuBar, which actively consumes mouse events (menu hover) — nothing ever propagated to QMainWindow, so T/TL/TR (and maximize-restore from top edge) were dead on every platform. Install an event filter on menuBar()/statusBar(): within the edge band it handles hover cursor, press (grabMouse), drag and release; outside the band everything passes through (menus open normally, native QSizeGrip corner drag preserved). 2. Layout minimum: MonitorPanel's minimumSizeHint (~1015px of stacked tables/group boxes) propagated through QTabWidget → central widget → QMainWindow, making the window minimum size 898x1121. Any vertical shrink was clamped dead. Set explicit per-tab-page minimumSize (320x200) + window minimumSize (760x480). Tests: test_resize.py now drives edges through real event delivery (childAt → child widget, propagation/filter path) for L/R/T/B/TL/TR/BL, verifies BR stays on QSizeGrip, maximize-restore via menubar filter, and minimum-size regression. All 8 suites pass.
849 lines
35 KiB
Python
849 lines
35 KiB
Python
"""
|
||
主窗口
|
||
- 左侧:主机列表 + 操作
|
||
- 右侧:标签页(终端、文件浏览、监控、AI Agent)
|
||
"""
|
||
import sys
|
||
import time
|
||
from typing import Optional
|
||
|
||
from PyQt5.QtCore import Qt, QSize, QEvent
|
||
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, load_settings, save_settings
|
||
|
||
|
||
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()
|
||
|
||
# 菜单栏/状态栏事件过滤器:
|
||
# 窗口上下边缘分别被 QMenuBar / QStatusBar 覆盖,QMenuBar 会主动消费
|
||
# 鼠标事件(悬停高亮菜单),事件不会传播到 QMainWindow,导致上边缘
|
||
# (及最大化时从顶部拖还原)永远失效。装过滤器在边缘带内截获处理。
|
||
self.menuBar().installEventFilter(self)
|
||
self.statusBar().installEventFilter(self)
|
||
|
||
# 左侧主机栏折叠状态
|
||
# 注意:Ctrl+B 快捷键只由「视图」菜单项(act_toggle_sidebar)承载,
|
||
# 不再额外建 QShortcut —— 否则一次按键触发两个绑定,会 toggle 两次等于没反应
|
||
self._sidebar_collapsed = False
|
||
# 浮动"显示侧栏"按钮(折叠后才可见)
|
||
self.btn_show_sidebar = QPushButton("▶\n主\n机", self)
|
||
self.btn_show_sidebar.setFixedSize(28, 80)
|
||
self.btn_show_sidebar.setToolTip("显示左侧主机栏 (Ctrl+B)")
|
||
self.btn_show_sidebar.clicked.connect(self._toggle_sidebar)
|
||
self.btn_show_sidebar.hide()
|
||
|
||
self._apply_theme() # 内部同时设置浮动按钮的主题样式
|
||
|
||
# 边缘拖动调整大小(含最大化时)—— 状态字段
|
||
self._resize_edge: str = "" # '' / 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'
|
||
self._resize_start_geo = None # 拖动开始时窗口 geometry
|
||
self._resize_start_pos = None # 拖动开始时鼠标全局坐标
|
||
# 边缘检测阈值(像素)
|
||
self._resize_margin = 6
|
||
# 最大化时拖动:是否正在拖(避免和正常拖动冲突)
|
||
self._dragging_from_max = False
|
||
# 鼠标光标缓存
|
||
self.setMouseTracking(True)
|
||
|
||
self._load_hosts_to_list()
|
||
|
||
# 恢复上次会话的侧栏折叠状态
|
||
if load_settings().get("sidebar_collapsed"):
|
||
self._toggle_sidebar()
|
||
|
||
# ============================================================
|
||
# 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)
|
||
self.splitter = splitter
|
||
root.addWidget(splitter)
|
||
|
||
# ====== 左侧:主机面板 ======
|
||
left = QWidget()
|
||
lv = QVBoxLayout(left)
|
||
lv.setContentsMargins(4, 4, 4, 4)
|
||
lv.setSpacing(6)
|
||
self.left_panel = left # 保存引用供折叠用
|
||
|
||
# 标题栏(带折叠按钮)
|
||
header = QHBoxLayout()
|
||
header.setContentsMargins(0, 0, 0, 0)
|
||
host_title = QLabel("🖥 主机")
|
||
host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;")
|
||
header.addWidget(host_title)
|
||
header.addStretch(1)
|
||
self.btn_collapse_sidebar = QPushButton("◀")
|
||
self.btn_collapse_sidebar.setFixedSize(24, 24)
|
||
self.btn_collapse_sidebar.setToolTip("隐藏左侧主机栏 (Ctrl+B)")
|
||
self.btn_collapse_sidebar.clicked.connect(self._toggle_sidebar)
|
||
header.addWidget(self.btn_collapse_sidebar)
|
||
lv.addLayout(header)
|
||
|
||
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])
|
||
|
||
# 各 Tab 页显式设小最小尺寸:否则 QTabWidget 取所有页 minimumSizeHint
|
||
# 的最大值(MonitorPanel 内部表格/分组堆出 ~1015px 高),一路传到
|
||
# QMainWindow,导致窗口最小高度 > 1100px —— 上下边缘拖动被 clamp 死,
|
||
# 表现为"上下不能拉"。QSplitter 子件默认 minimumSize=0,压缩无碍。
|
||
for _page in (self.terminal_panel, self.file_browser,
|
||
self.monitor, self.ai_panel):
|
||
_page.setMinimumSize(320, 200)
|
||
# 窗口级合理下限(显式 minimumSize 覆盖布局算出的巨大值)
|
||
self.setMinimumSize(760, 480)
|
||
|
||
def _toggle_sidebar(self):
|
||
"""折叠/展开左侧主机栏"""
|
||
self._sidebar_collapsed = not self._sidebar_collapsed
|
||
# 同步菜单项状态
|
||
if hasattr(self, "act_toggle_sidebar"):
|
||
self.act_toggle_sidebar.setChecked(self._sidebar_collapsed)
|
||
label = "▶ 展开侧栏 (Ctrl+B)" if self._sidebar_collapsed else "◀ 隐藏侧栏 (Ctrl+B)"
|
||
self.act_toggle_sidebar.setText(label)
|
||
if self._sidebar_collapsed:
|
||
# 隐藏左侧 panel
|
||
self.left_panel.hide()
|
||
# 浮动按钮显示
|
||
self.btn_show_sidebar.show()
|
||
self._position_show_sidebar_btn()
|
||
# 折叠按钮文本变 ▶
|
||
self.btn_collapse_sidebar.setText("▶")
|
||
self.btn_collapse_sidebar.setToolTip("展开左侧主机栏 (Ctrl+B)")
|
||
else:
|
||
self.left_panel.show()
|
||
self.btn_show_sidebar.hide()
|
||
self.btn_collapse_sidebar.setText("◀")
|
||
self.btn_collapse_sidebar.setToolTip("隐藏左侧主机栏 (Ctrl+B)")
|
||
# 持久化折叠状态(下次启动时恢复)
|
||
s = load_settings()
|
||
s["sidebar_collapsed"] = self._sidebar_collapsed
|
||
save_settings(s)
|
||
|
||
def _position_show_sidebar_btn(self):
|
||
"""浮动按钮固定在主窗口左边缘内侧。
|
||
|
||
按钮 parent 是主窗口,move() 用父级局部坐标即可 ——
|
||
切勿用 mapToGlobal 的全局坐标(窗口不在屏幕原点时按钮会飘走)。
|
||
局部坐标固定后,窗口 resize/move 也无需重新定位。
|
||
"""
|
||
self.btn_show_sidebar.move(4, 100)
|
||
self.btn_show_sidebar.raise_()
|
||
|
||
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_toggle_sidebar = QAction("◀ 隐藏侧栏 (Ctrl+B)", self, checkable=True)
|
||
self.act_toggle_sidebar.setShortcut("Ctrl+B")
|
||
self.act_toggle_sidebar.triggered.connect(self._toggle_sidebar)
|
||
m_view.addAction(self.act_toggle_sidebar)
|
||
m_view.addSeparator()
|
||
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):
|
||
"""已废弃:旧版单命令输入框的遗留接口,现终端标签自行管理输入。"""
|
||
return
|
||
|
||
# ============================================================
|
||
# 主机 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)
|
||
self._apply_floating_btn_style()
|
||
|
||
def _apply_floating_btn_style(self):
|
||
"""浮动按钮跟随明暗主题(悬浮在内容上方,需要自己的底色)"""
|
||
if get_theme() == "dark":
|
||
self.btn_show_sidebar.setStyleSheet(
|
||
"QPushButton { background: #313244; color: #cdd6f4; border: 1px solid #45475a;"
|
||
" border-radius: 4px; font-size: 10pt; }"
|
||
"QPushButton:hover { background: #45475a; border-color: #89b4fa; }"
|
||
)
|
||
else:
|
||
self.btn_show_sidebar.setStyleSheet(
|
||
"QPushButton { background: #ffffff; color: #333333; border: 1px solid #bbbbbb;"
|
||
" border-radius: 4px; font-size: 10pt; }"
|
||
"QPushButton:hover { background: #e3f2fd; border-color: #1976d2; }"
|
||
)
|
||
|
||
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 Agent(OpenAI 兼容 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 _hit_test_edge(self, pos) -> str:
|
||
"""根据 pos(窗口内坐标)返回哪条边被命中
|
||
返回 '' / 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'
|
||
|
||
最大化/全屏时不返回(避免和系统最大化手势冲突)。
|
||
"""
|
||
if self.isMaximized() or self.isFullScreen():
|
||
return ""
|
||
w, h = self.width(), self.height()
|
||
m = self._resize_margin
|
||
x, y = pos.x(), pos.y()
|
||
# 角
|
||
if x <= m and y <= m:
|
||
return "TL"
|
||
if x >= w - m and y <= m:
|
||
return "TR"
|
||
if x <= m and y >= h - m:
|
||
return "BL"
|
||
if x >= w - m and y >= h - m:
|
||
return "BR"
|
||
# 边
|
||
if x <= m:
|
||
return "L"
|
||
if x >= w - m:
|
||
return "R"
|
||
if y <= m:
|
||
return "T"
|
||
if y >= h - m:
|
||
return "B"
|
||
return ""
|
||
|
||
def _edge_to_cursor(self, edge: str, widget=None):
|
||
from PyQt5.QtGui import QCursor
|
||
from PyQt5.QtCore import Qt as _Qt
|
||
cursors = {
|
||
"L": _Qt.SizeHorCursor, "R": _Qt.SizeHorCursor,
|
||
"T": _Qt.SizeVerCursor, "B": _Qt.SizeVerCursor,
|
||
"TL": _Qt.SizeFDiagCursor, "BR": _Qt.SizeFDiagCursor,
|
||
"TR": _Qt.SizeBDiagCursor, "BL": _Qt.SizeBDiagCursor,
|
||
}
|
||
c = cursors.get(edge)
|
||
if c is not None:
|
||
(widget or self).setCursor(QCursor(c))
|
||
|
||
def _restore_cursor(self, widget=None):
|
||
(widget or self).unsetCursor()
|
||
|
||
def eventFilter(self, obj, e):
|
||
"""拦截菜单栏/状态栏上的鼠标事件,实现上/下边缘拖动调整窗口。
|
||
|
||
QMenuBar 主动消费鼠标事件(悬停高亮菜单项),事件不会向上传播到
|
||
QMainWindow,上边缘 hit-test 永远收不到事件;状态栏在部分平台/样式
|
||
下同理。过滤器只在 6px 边缘带内接管,其余区域原样放行
|
||
(菜单照常点开、QSizeGrip 原生右下角拖动不受影响)。
|
||
"""
|
||
mb, sb = self.menuBar(), self.statusBar()
|
||
if obj is mb or obj is sb:
|
||
et = e.type()
|
||
if et == QEvent.MouseMove:
|
||
# 拖动中:press 时已 grabMouse,move 持续走这里
|
||
if self._resize_edge and (e.buttons() & Qt.LeftButton):
|
||
self._do_resize(e.globalPos())
|
||
return True
|
||
# 悬停:边缘带内显示调整光标并屏蔽菜单高亮,带外放行
|
||
if not e.buttons():
|
||
edge = self._hit_test_edge(obj.mapTo(self, e.pos()))
|
||
if edge:
|
||
self._edge_to_cursor(edge, obj)
|
||
return True
|
||
self._restore_cursor(obj)
|
||
return False
|
||
if et == QEvent.MouseButtonPress and e.button() == Qt.LeftButton:
|
||
# 最大化时:菜单栏顶部边缘按下 → 还原并跟随鼠标
|
||
if obj is mb and self.isMaximized() and e.pos().y() <= self._resize_margin:
|
||
self._restore_from_max(e.globalPos())
|
||
return True
|
||
edge = self._hit_test_edge(obj.mapTo(self, e.pos()))
|
||
if edge:
|
||
self._resize_edge = edge
|
||
self._resize_start_geo = self.geometry()
|
||
self._resize_start_pos = e.globalPos()
|
||
obj.grabMouse() # 后续 move/release 全部经过本过滤器
|
||
return True
|
||
return False
|
||
if et == QEvent.MouseButtonRelease and self._resize_edge:
|
||
self._resize_edge = ""
|
||
self._resize_start_geo = None
|
||
self._resize_start_pos = None
|
||
obj.releaseMouse()
|
||
return True
|
||
if et == QEvent.Leave:
|
||
self._restore_cursor(obj)
|
||
return False
|
||
return super().eventFilter(obj, e)
|
||
|
||
def mouseMoveEvent(self, e):
|
||
# 拖动中
|
||
if self._resize_edge and self._resize_start_geo and self._resize_start_pos:
|
||
self._do_resize(e.globalPos())
|
||
return
|
||
# 否则只更新光标
|
||
if not self.isMaximized() and not self.isFullScreen():
|
||
edge = self._hit_test_edge(e.pos())
|
||
if edge:
|
||
self._edge_to_cursor(edge)
|
||
else:
|
||
self._restore_cursor()
|
||
else:
|
||
self._restore_cursor()
|
||
super().mouseMoveEvent(e)
|
||
|
||
def mousePressEvent(self, e):
|
||
if e.button() == Qt.LeftButton:
|
||
# 特殊情况:窗口最大化时,从顶部边缘按下 → 还原并跟随鼠标调整
|
||
if self.isMaximized() and e.pos().y() <= self._resize_margin:
|
||
self._restore_from_max(e.globalPos())
|
||
e.accept()
|
||
return
|
||
edge = self._hit_test_edge(e.pos())
|
||
if edge:
|
||
self._resize_edge = edge
|
||
self._resize_start_geo = self.geometry()
|
||
self._resize_start_pos = e.globalPos()
|
||
e.accept()
|
||
return
|
||
super().mousePressEvent(e)
|
||
|
||
def _restore_from_max(self, global_pos):
|
||
"""从最大化状态恢复窗口,并将宽度按鼠标位置调整"""
|
||
# 记录当前最大化的位置
|
||
self._dragging_from_max = True
|
||
# 屏幕可用区域
|
||
screen = QApplication.primaryScreen().availableGeometry()
|
||
# 还原(先恢复原始 geometry)
|
||
self.showNormal()
|
||
# 设定宽度:按鼠标 X 位置占屏幕的比例
|
||
ratio = max(0.2, min(0.8, (global_pos.x() - screen.x()) / screen.width()))
|
||
new_w = int(screen.width() * ratio)
|
||
new_w = max(self.minimumWidth(), new_w)
|
||
new_h = int(screen.height() * 0.85)
|
||
new_h = max(self.minimumHeight(), new_h)
|
||
new_x = max(screen.x(), global_pos.x() - new_w // 2)
|
||
new_y = screen.y() + (screen.height() - new_h) // 2
|
||
self.setGeometry(new_x, new_y, new_w, new_h)
|
||
# 让后续 mouseMove 继续调整
|
||
self._resize_edge = "TL" # 模拟从左上角拖
|
||
self._resize_start_geo = self.geometry()
|
||
self._resize_start_pos = global_pos
|
||
|
||
def mouseReleaseEvent(self, e):
|
||
if self._resize_edge:
|
||
self._resize_edge = ""
|
||
self._resize_start_geo = None
|
||
self._resize_start_pos = None
|
||
e.accept()
|
||
return
|
||
super().mouseReleaseEvent(e)
|
||
|
||
def _do_resize(self, global_pos):
|
||
"""根据当前鼠标位置和拖动方向调整窗口 geometry"""
|
||
geo = self._resize_start_geo
|
||
if geo is None:
|
||
return
|
||
dx = global_pos.x() - self._resize_start_pos.x()
|
||
dy = global_pos.y() - self._resize_start_pos.y()
|
||
# 屏幕可用区域(用于限制)
|
||
try:
|
||
screen = QApplication.primaryScreen().availableGeometry()
|
||
except Exception:
|
||
from PyQt5.QtCore import QRect
|
||
screen = QRect(0, 0, 10000, 10000)
|
||
# 最小尺寸:尊重 QMainWindow 自己的 minimumWidth/minimumHeight
|
||
min_w = self.minimumWidth() if self.minimumWidth() > 0 else 400
|
||
min_h = self.minimumHeight() if self.minimumHeight() > 0 else 300
|
||
new_x, new_y, new_w, new_h = geo.x(), geo.y(), geo.width(), geo.height()
|
||
|
||
edge = self._resize_edge
|
||
# 左边
|
||
if "L" in edge:
|
||
new_x = geo.x() + dx
|
||
new_w = geo.width() - dx
|
||
if new_w < min_w:
|
||
new_w = min_w
|
||
new_x = geo.x() + geo.width() - min_w
|
||
# 右边
|
||
if "R" in edge:
|
||
new_w = geo.width() + dx
|
||
if new_w < min_w:
|
||
new_w = min_w
|
||
# 上边
|
||
if "T" in edge:
|
||
new_y = geo.y() + dy
|
||
new_h = geo.height() - dy
|
||
if new_h < min_h:
|
||
new_h = min_h
|
||
new_y = geo.y() + geo.height() - min_h
|
||
# 下边
|
||
if "B" in edge:
|
||
new_h = geo.height() + dy
|
||
if new_h < min_h:
|
||
new_h = min_h
|
||
# 屏幕限制
|
||
if new_x < screen.x():
|
||
new_x = screen.x()
|
||
if new_y < screen.y():
|
||
new_y = screen.y()
|
||
if new_x + new_w > screen.x() + screen.width():
|
||
new_w = screen.x() + screen.width() - new_x
|
||
if new_y + new_h > screen.y() + screen.height():
|
||
new_h = screen.y() + screen.height() - new_y
|
||
# 拆成 move + resize + setGeometry 兜底(部分平台 setGeometry 会失败)
|
||
self.resize(new_w, new_h)
|
||
self.move(new_x, new_y)
|
||
if (self.width(), self.height()) != (new_w, new_h):
|
||
self.setGeometry(new_x, new_y, new_w, new_h)
|
||
|
||
def leaveEvent(self, e):
|
||
if not self._resize_edge:
|
||
self._restore_cursor()
|
||
super().leaveEvent(e)
|
||
|
||
def changeEvent(self, e):
|
||
"""窗口状态变化:清除残留的拖动状态"""
|
||
from PyQt5.QtCore import QEvent as _QEvent
|
||
if e.type() == _QEvent.WindowStateChange:
|
||
# 状态变化(最大化/还原)时清掉拖动状态
|
||
self._resize_edge = ""
|
||
self._resize_start_geo = None
|
||
self._resize_start_pos = None
|
||
self._restore_cursor()
|
||
super().changeEvent(e)
|
||
|
||
def closeEvent(self, e):
|
||
try:
|
||
self.monitor._stop_worker()
|
||
self.terminal_panel.shutdown()
|
||
self.manager.close_all()
|
||
except Exception:
|
||
pass
|
||
super().closeEvent(e)
|