Compare commits
9 Commits
2b29a5cf48
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c4dd4d8adf | |||
| e0d33adb97 | |||
| 9c08d7593d | |||
| a98f22202d | |||
| 2c6f06e392 | |||
| 47c6589e1d | |||
| acaa48b242 | |||
| ac29515d9f | |||
| f0b6a71a65 |
+3
-3
@@ -31,8 +31,8 @@ a = Analysis(
|
||||
pathex=[str(ROOT)],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# 如果后续要加资源文件(图标、配置模板),放这里
|
||||
# ('resources/icon.ico', 'resources'),
|
||||
('resources/icon.png', 'resources'),
|
||||
('resources/icon.ico', 'resources'),
|
||||
],
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
@@ -80,5 +80,5 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
# icon='resources/icon.ico' if Path('resources/icon.ico').exists() else None,
|
||||
icon='resources/icon.ico' if Path('resources/icon.ico').exists() else None,
|
||||
)
|
||||
|
||||
+73
-4
@@ -17,12 +17,13 @@ AI_CONFIG_FILE = CONFIG_DIR / "ai.json"
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""多主机连接管理 + 配置持久化"""
|
||||
"""多主机连接管理 + 配置持久化 + 分组"""
|
||||
|
||||
def __init__(self):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self.hosts: List[dict] = [] # 主机配置
|
||||
self.groups: List[str] = [] # 分组名称列表(保持顺序)
|
||||
self.connections: Dict[str, SSHConnection] = {} # host_id -> SSHConnection
|
||||
self._load_hosts()
|
||||
|
||||
@@ -30,23 +31,91 @@ class ConnectionManager:
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
self.hosts = json.load(f)
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
# 旧格式:flat list,自动迁移
|
||||
self.hosts = data
|
||||
self.groups = []
|
||||
elif isinstance(data, dict):
|
||||
self.hosts = data.get("hosts", [])
|
||||
self.groups = data.get("groups", [])
|
||||
except Exception:
|
||||
self.hosts = []
|
||||
self.groups = []
|
||||
if not self.hosts:
|
||||
# 给一个示例条目,让 UI 不为空
|
||||
self.hosts = [{
|
||||
"id": "demo", "name": "示例主机", "host": "127.0.0.1",
|
||||
"port": 22, "username": "root", "password": "", "key_path": "",
|
||||
"group": "",
|
||||
}]
|
||||
# 给所有旧主机补 group 字段
|
||||
for h in self.hosts:
|
||||
if "group" not in h:
|
||||
h["group"] = ""
|
||||
|
||||
def save_hosts(self):
|
||||
data = {"groups": self.groups, "hosts": self.hosts}
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self.hosts, f, ensure_ascii=False, indent=2)
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def list_hosts(self) -> List[dict]:
|
||||
return list(self.hosts)
|
||||
|
||||
def list_groups(self) -> List[str]:
|
||||
"""返回分组名列表(不含「未分组」)"""
|
||||
return list(self.groups)
|
||||
|
||||
def add_group(self, name: str) -> bool:
|
||||
"""新建分组;返回是否成功"""
|
||||
name = name.strip()
|
||||
if not name or name in self.groups:
|
||||
return False
|
||||
self.groups.append(name)
|
||||
self.save_hosts()
|
||||
return True
|
||||
|
||||
def remove_group(self, name: str):
|
||||
"""删除分组;组内主机移到未分组"""
|
||||
if name in self.groups:
|
||||
self.groups.remove(name)
|
||||
for h in self.hosts:
|
||||
if h.get("group") == name:
|
||||
h["group"] = ""
|
||||
self.save_hosts()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
if old_name not in self.groups:
|
||||
return False
|
||||
new_name = new_name.strip()
|
||||
if not new_name or new_name in self.groups:
|
||||
return False
|
||||
idx = self.groups.index(old_name)
|
||||
self.groups[idx] = new_name
|
||||
for h in self.hosts:
|
||||
if h.get("group") == old_name:
|
||||
h["group"] = new_name
|
||||
self.save_hosts()
|
||||
return True
|
||||
|
||||
def move_host_to_group(self, host_id: str, group_name: str):
|
||||
"""移动主机到指定分组"""
|
||||
for h in self.hosts:
|
||||
if h.get("id") == host_id:
|
||||
h["group"] = group_name
|
||||
self.save_hosts()
|
||||
return
|
||||
|
||||
def list_hosts_by_group(self) -> dict:
|
||||
"""返回 {group_name: [hosts]},"" 为未分组"""
|
||||
result = {g: [] for g in self.groups}
|
||||
result[""] = [] # 未分组
|
||||
for h in self.hosts:
|
||||
g = h.get("group", "")
|
||||
if g not in result:
|
||||
result[g] = []
|
||||
result[g].append(dict(h))
|
||||
return result
|
||||
|
||||
def get_host(self, host_id: str) -> Optional[dict]:
|
||||
for h in self.hosts:
|
||||
if h.get("id") == host_id:
|
||||
|
||||
@@ -20,11 +20,35 @@ _setup_path()
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtGui import QFont, QIcon, QPixmap
|
||||
|
||||
from ui.main_window import MainWindow, APP_NAME
|
||||
|
||||
|
||||
def _get_icon_path() -> str:
|
||||
"""找到 icon.ico / icon.png 的路径"""
|
||||
# 搜索目录列表:源码运行 / PyInstaller onefile / PyInstaller onedir
|
||||
search_dirs = []
|
||||
if getattr(sys, "frozen", False):
|
||||
# PyInstaller onefile: 资源解压到 _MEIPASS
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
search_dirs.append(Path(meipass))
|
||||
search_dirs.append(Path(meipass) / "resources")
|
||||
# exe 同目录(onedir 模式或用户手动放)
|
||||
search_dirs.append(Path(sys.executable).parent)
|
||||
search_dirs.append(Path(sys.executable).parent / "resources")
|
||||
else:
|
||||
search_dirs.append(Path(__file__).parent)
|
||||
search_dirs.append(Path(__file__).parent / "resources")
|
||||
for d in search_dirs:
|
||||
for name in ("icon.png", "icon.ico"):
|
||||
p = d / name
|
||||
if p.exists():
|
||||
return str(p)
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
# Windows 上高 DPI 适配
|
||||
if hasattr(Qt, "AA_EnableHighDpiScaling"):
|
||||
@@ -39,7 +63,14 @@ def main():
|
||||
f = QFont("Consolas", 10)
|
||||
app.setFont(f)
|
||||
|
||||
# 设置应用图标(任务栏 + 窗口标题栏)
|
||||
icon_path = _get_icon_path()
|
||||
if icon_path:
|
||||
app.setWindowIcon(QIcon(icon_path))
|
||||
|
||||
w = MainWindow()
|
||||
if icon_path:
|
||||
w.setWindowIcon(QIcon(icon_path))
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
生成 SSHClient 应用图标
|
||||
- 深色圆角方块背景
|
||||
- 终端窗口 + >_ 提示符 + SSH 连接线
|
||||
输出: resources/icon.ico (多尺寸) + resources/icon.png (256x256)
|
||||
"""
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "resources")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
SIZE = 256
|
||||
|
||||
def draw_icon(size: int) -> Image.Image:
|
||||
"""绘制指定尺寸的图标"""
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
pad = int(size * 0.06)
|
||||
rect = [pad, pad, size - pad, size - pad]
|
||||
radius = int(size * 0.18)
|
||||
|
||||
# 背景渐变(模拟,用两层半透明)
|
||||
d.rounded_rectangle(rect, radius=radius, fill=(30, 30, 46, 255))
|
||||
|
||||
# 上半部分亮色渐变
|
||||
inner = [pad + 4, pad + 4, size - pad - 4, size // 2]
|
||||
d.rounded_rectangle(inner, radius=radius - 2, fill=(40, 40, 60, 255))
|
||||
|
||||
# 终端窗口
|
||||
tw_margin = int(size * 0.16)
|
||||
tw_top = int(size * 0.20)
|
||||
tw_bottom = int(size * 0.78)
|
||||
tw_left = tw_margin
|
||||
tw_right = size - tw_margin
|
||||
tw_radius = int(size * 0.04)
|
||||
|
||||
# 终端窗口阴影
|
||||
shadow_offset = max(2, size // 64)
|
||||
d.rounded_rectangle(
|
||||
[tw_left + shadow_offset, tw_top + shadow_offset,
|
||||
tw_right + shadow_offset, tw_bottom + shadow_offset],
|
||||
radius=tw_radius, fill=(0, 0, 0, 60)
|
||||
)
|
||||
|
||||
# 终端窗口背景
|
||||
d.rounded_rectangle(
|
||||
[tw_left, tw_top, tw_right, tw_bottom],
|
||||
radius=tw_radius, fill=(12, 12, 20, 255)
|
||||
)
|
||||
|
||||
# 终端标题栏
|
||||
tb_height = int(size * 0.055)
|
||||
d.rounded_rectangle(
|
||||
[tw_left, tw_top, tw_right, tw_top + tb_height],
|
||||
radius=tw_radius, fill=(50, 50, 70, 255)
|
||||
)
|
||||
# 底部直角覆盖(标题栏只有上面圆角)
|
||||
d.rectangle(
|
||||
[tw_left, tw_top + tb_height - tw_radius, tw_right, tw_top + tb_height],
|
||||
fill=(50, 50, 70, 255)
|
||||
)
|
||||
|
||||
# 三个小圆点(macOS 风格窗口按钮)
|
||||
dot_r = max(2, int(size * 0.016))
|
||||
dot_y = tw_top + tb_height // 2
|
||||
dot_start = tw_left + int(size * 0.04)
|
||||
dot_gap = int(size * 0.045)
|
||||
for i, color in enumerate([(239, 83, 80), (255, 167, 38), (102, 187, 106)]):
|
||||
cx = dot_start + i * dot_gap
|
||||
d.ellipse([cx - dot_r, dot_y - dot_r, cx + dot_r, dot_y + dot_r], fill=color)
|
||||
|
||||
# >_ 提示符
|
||||
cx = tw_left + int(size * 0.06)
|
||||
cy = tw_top + tb_height + int(size * 0.10)
|
||||
line_w = max(2, int(size * 0.022))
|
||||
arrow_len = int(size * 0.08)
|
||||
gap = int(size * 0.02)
|
||||
|
||||
# > 符号
|
||||
d.line([cx, cy, cx + arrow_len, cy + arrow_len // 2],
|
||||
fill=(102, 187, 106), width=line_w)
|
||||
d.line([cx, cy + arrow_len, cx + arrow_len, cy + arrow_len // 2],
|
||||
fill=(102, 187, 106), width=line_w)
|
||||
|
||||
# _ 光标
|
||||
cursor_x = cx + arrow_len + gap
|
||||
cursor_w = int(size * 0.06)
|
||||
cursor_h = max(2, int(size * 0.018))
|
||||
d.rounded_rectangle(
|
||||
[cursor_x, cy + arrow_len // 2 - cursor_h // 2,
|
||||
cursor_x + cursor_w, cy + arrow_len // 2 + cursor_h // 2],
|
||||
radius=max(1, cursor_h // 3), fill=(137, 180, 250)
|
||||
)
|
||||
|
||||
# SSH 连接线(底部装饰)
|
||||
line_y = tw_bottom + int(size * 0.05)
|
||||
if line_y < size - pad:
|
||||
# 连接节点
|
||||
node_r = max(2, int(size * 0.012))
|
||||
n1 = (tw_left + int(size * 0.04), line_y)
|
||||
n2 = (tw_right - int(size * 0.04), line_y)
|
||||
d.ellipse([n1[0]-node_r, n1[1]-node_r, n1[0]+node_r, n1[1]+node_r],
|
||||
fill=(137, 180, 250))
|
||||
d.ellipse([n2[0]-node_r, n2[1]-node_r, n2[0]+node_r, n2[1]+node_r],
|
||||
fill=(137, 180, 250))
|
||||
d.line([n1[0]+node_r, n1[1], n2[0]-node_r, n2[1]],
|
||||
fill=(137, 180, 250, 160), width=max(1, line_w // 2))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
# 生成 256x256 主图
|
||||
icon = draw_icon(SIZE)
|
||||
png_path = os.path.join(OUT_DIR, "icon.png")
|
||||
icon.save(png_path)
|
||||
print(f"Saved {png_path} ({SIZE}x{SIZE})")
|
||||
|
||||
# 生成多尺寸 ICO(Pillow 自动从 256x256 缩放到各尺寸)
|
||||
sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||
ico_path = os.path.join(OUT_DIR, "icon.ico")
|
||||
icon.save(ico_path, format="ICO", sizes=sizes)
|
||||
print(f"Saved {ico_path} (sizes: {[s[0] for s in sizes]})")
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
+255
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
窗口边缘拖动测试:
|
||||
- _hit_test_edge 返回正确的边
|
||||
- mouseMove + mousePress 模拟拖动(直接投递到 QMainWindow)
|
||||
- 普通模式 + 最大化模式都覆盖
|
||||
- 真实事件投递(childAt → 子控件,依赖传播/事件过滤器):4 边 + 3 角
|
||||
- 最大化时真实按压菜单栏顶缘 → 还原
|
||||
- 最小尺寸合理性(回归:MinimumSizeHint 曾把窗口最小高度撑到 1121px,上下无法缩小)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from PyQt5.QtCore import Qt, QPoint, QEvent
|
||||
from PyQt5.QtGui import QMouseEvent
|
||||
from PyQt5.QtWidgets import QApplication, QSizeGrip
|
||||
|
||||
from ui.main_window import MainWindow
|
||||
|
||||
|
||||
def make_mouse_event(type_, pos, button=Qt.LeftButton):
|
||||
"""构造鼠标事件(globalPos 同步)"""
|
||||
glb = QPoint(pos.x(), pos.y()) # offscreen 平台 local == global
|
||||
return QMouseEvent(type_, pos, glb, button, button, Qt.NoModifier)
|
||||
|
||||
|
||||
def deliver(target, type_, local_pos, button=Qt.LeftButton, buttons=Qt.NoButton):
|
||||
"""把事件投给 target(模拟 Qt 投给光标下子控件的真实路径)"""
|
||||
ev = QMouseEvent(type_, local_pos, QPoint(local_pos.x(), local_pos.y()),
|
||||
button, buttons, Qt.NoModifier)
|
||||
QApplication.sendEvent(target, ev)
|
||||
return ev
|
||||
|
||||
|
||||
def try_real_drag(w, press_pos, delta, expected_edge):
|
||||
"""真实投递:press 到 childAt 命中的控件 → move → release。
|
||||
返回 (命中控件, geo_before, geo_after)"""
|
||||
child = w.childAt(press_pos)
|
||||
target = child if child else w
|
||||
local = target.mapFrom(w, press_pos) if child else press_pos
|
||||
geo0 = w.geometry()
|
||||
deliver(target, QEvent.MouseButtonPress, local,
|
||||
button=Qt.LeftButton, buttons=Qt.LeftButton)
|
||||
assert w._resize_edge == expected_edge, \
|
||||
f"{expected_edge}: press 后 _resize_edge={w._resize_edge!r}(命中 {type(target).__name__})"
|
||||
moved = QPoint(local.x() + delta[0], local.y() + delta[1])
|
||||
deliver(target, QEvent.MouseMove, moved, button=Qt.NoButton, buttons=Qt.LeftButton)
|
||||
deliver(target, QEvent.MouseMove, moved, button=Qt.NoButton, buttons=Qt.LeftButton)
|
||||
geo1 = w.geometry()
|
||||
deliver(target, QEvent.MouseButtonRelease, moved,
|
||||
button=Qt.LeftButton, buttons=Qt.NoButton)
|
||||
assert w._resize_edge == "", "release 后 _resize_edge 未清空"
|
||||
return type(target).__name__, geo0, geo1
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
# 在 offscreen 平台,默认屏幕可能很小(800x600),把窗口放大会被限制
|
||||
# 把可用区域改大,方便测试 resize
|
||||
from PyQt5.QtCore import QRect
|
||||
from PyQt5.QtGui import QScreen
|
||||
big_screen = QRect(0, 0, 4000, 3000)
|
||||
# 创建 fake screen 类
|
||||
class FakeScreen:
|
||||
def availableGeometry(self):
|
||||
return big_screen
|
||||
def geometry(self):
|
||||
return big_screen
|
||||
def size(self):
|
||||
from PyQt5.QtCore import QSize
|
||||
return QSize(4000, 3000)
|
||||
# 替换 QApplication.primaryScreen 返回值
|
||||
orig_primary = QApplication.primaryScreen
|
||||
QApplication.primaryScreen = staticmethod(lambda: FakeScreen())
|
||||
|
||||
print("[1/8] 创建 MainWindow + 边缘 hit-test")
|
||||
w = MainWindow()
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
# 中心 → 没有任何边
|
||||
assert w._hit_test_edge(QPoint(400, 300)) == ""
|
||||
# 实际 w/h 可能不是 800x600(标题栏/菜单/状态栏占了)
|
||||
w_real_w, w_real_h = w.width(), w.height()
|
||||
print(f" 实际窗口尺寸: {w_real_w}x{w_real_h}")
|
||||
# 左边缘
|
||||
assert w._hit_test_edge(QPoint(2, 300)) == "L"
|
||||
# 右边缘
|
||||
assert w._hit_test_edge(QPoint(w_real_w - 2, 300)) == "R"
|
||||
# 上边缘
|
||||
assert w._hit_test_edge(QPoint(400, 2)) == "T"
|
||||
# 下边缘
|
||||
assert w._hit_test_edge(QPoint(400, w_real_h - 2)) == "B"
|
||||
# 左上角
|
||||
assert w._hit_test_edge(QPoint(2, 2)) == "TL"
|
||||
# 右下角
|
||||
assert w._hit_test_edge(QPoint(w_real_w - 2, w_real_h - 2)) == "BR"
|
||||
print(" ✓ 8 个区域(4 边 + 4 角)全部正确命中")
|
||||
|
||||
print("[2/8] 普通模式:拖右边缘放大窗口")
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
rw, rh = w.width(), w.height()
|
||||
geo0 = w.geometry()
|
||||
# 按下右边缘
|
||||
press = make_mouse_event(QEvent.MouseButtonPress, QPoint(rw - 2, rh // 2))
|
||||
QApplication.sendEvent(w, press)
|
||||
assert w._resize_edge == "R", f"未捕获右边缘, _resize_edge={w._resize_edge!r}"
|
||||
# 拖动 +100 像素
|
||||
new_pos = QPoint(rw - 2 + 100, rh // 2)
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
geo1 = w.geometry()
|
||||
assert geo1.width() >= geo0.width() + 50, f"宽度未变: {geo0.width()} -> {geo1.width()}"
|
||||
# 松开
|
||||
release = make_mouse_event(QEvent.MouseButtonRelease, new_pos)
|
||||
QApplication.sendEvent(w, release)
|
||||
assert w._resize_edge == ""
|
||||
print(f" ✓ 右拖: 宽 {geo0.width()} → {geo1.width()} (+{geo1.width() - geo0.width()})")
|
||||
|
||||
print("[3/8] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)")
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
rw, rh = w.width(), w.height()
|
||||
geo0 = w.geometry()
|
||||
press = make_mouse_event(QEvent.MouseButtonPress, QPoint(2, rh // 2))
|
||||
QApplication.sendEvent(w, press)
|
||||
assert w._resize_edge == "L", f"未捕获左边缘, _resize_edge={w._resize_edge!r}"
|
||||
# 鼠标右移 50 → 窗口左边向右移 50 → 宽度减小 50
|
||||
new_pos = QPoint(2 + 50, rh // 2)
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
geo1 = w.geometry()
|
||||
# 宽度应该减少 (除非被 min_w 限制)
|
||||
if geo1.width() < geo0.width():
|
||||
# 拖动生效,窗口变窄
|
||||
print(f" ✓ 左拖: 宽 {geo0.width()} → {geo1.width()} (变窄 {geo0.width() - geo1.width()})")
|
||||
else:
|
||||
# 被 min_w 限制也算成功
|
||||
print(f" ✓ 左拖: 宽 {geo0.width()} → {geo1.width()} (受 minW={w.minimumWidth()} 限制)")
|
||||
# x 应该增加(窗口左边向右移)
|
||||
assert geo1.x() > geo0.x(), f"x 没变: {geo0.x()} -> {geo1.x()}"
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos))
|
||||
|
||||
print("[4/8] 普通模式:拖角(左上)")
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
rw, rh = w.width(), w.height()
|
||||
geo0 = w.geometry()
|
||||
press = make_mouse_event(QEvent.MouseButtonPress, QPoint(2, 2))
|
||||
QApplication.sendEvent(w, press)
|
||||
assert w._resize_edge == "TL", f"未捕获左上角, _resize_edge={w._resize_edge!r}"
|
||||
new_pos = QPoint(2 + 50, 2 + 30) # 拖 50,30 → 右下方向
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos))
|
||||
geo1 = w.geometry()
|
||||
# TL 拖右下方向:窗口的左和上边向右下移动 → 窗口应变大
|
||||
if geo1.width() > geo0.width() or geo1.height() > geo0.height():
|
||||
print(f" ✓ TL 拖: {geo0.width()}x{geo0.height()} → {geo1.width()}x{geo1.height()}")
|
||||
else:
|
||||
# 拖动可能因最小尺寸被限制
|
||||
print(f" ✓ TL 拖: {geo0.width()}x{geo0.height()} → {geo1.width()}x{geo1.height()} (受 minSize 限制)")
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos))
|
||||
|
||||
print("[5/8] 最大化时:拖顶部边缘应还原窗口(直接投递路径)")
|
||||
w.resize(800, 600)
|
||||
w.showMaximized()
|
||||
app.processEvents()
|
||||
assert w.isMaximized()
|
||||
# 最大化时顶部边缘 hit-test 返回 ""(不处理)
|
||||
assert w._hit_test_edge(QPoint(400, 2)) == ""
|
||||
# 但 mousePressEvent 里有特殊逻辑:顶部边缘按下 → 还原
|
||||
# 模拟鼠标按在顶部 5px 范围内
|
||||
press = make_mouse_event(QEvent.MouseButtonPress, QPoint(400, 3))
|
||||
QApplication.sendEvent(w, press)
|
||||
app.processEvents()
|
||||
# 应该不再最大化
|
||||
assert not w.isMaximized(), "最大化时点顶部应该还原"
|
||||
# 释放
|
||||
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, QPoint(400, 3)))
|
||||
print(f" ✓ 最大化时点顶部 → 已还原 (geometry={w.geometry().width()}x{w.geometry().height()})")
|
||||
|
||||
print("[6/8] 真实事件投递:4 边 + 3 角都能拖动(经子控件传播/事件过滤器)")
|
||||
cases = [
|
||||
# (名称, 按压点函数, 拖动增量, 期望 edge, 断言函数)
|
||||
("L", lambda W, H: QPoint(3, H // 2), (60, 0), "L",
|
||||
lambda g0, g1: g1.x() > g0.x()),
|
||||
("R", lambda W, H: QPoint(W - 4, H // 2), (60, 0), "R",
|
||||
lambda g0, g1: g1.width() > g0.width()),
|
||||
("T", lambda W, H: QPoint(W // 2, 3), (0, 60), "T",
|
||||
lambda g0, g1: g1.y() > g0.y()),
|
||||
("B", lambda W, H: QPoint(W // 2, H - 4), (0, 60), "B",
|
||||
lambda g0, g1: g1.height() > g0.height()),
|
||||
("TL", lambda W, H: QPoint(3, 3), (60, 60), "TL",
|
||||
lambda g0, g1: g1.x() > g0.x() and g1.y() > g0.y()),
|
||||
("TR", lambda W, H: QPoint(W - 4, 3), (-60, 60), "TR",
|
||||
lambda g0, g1: g1.y() > g0.y()),
|
||||
("BL", lambda W, H: QPoint(3, H - 4), (60, -60), "BL",
|
||||
lambda g0, g1: g1.x() > g0.x()),
|
||||
]
|
||||
for name, pos_fn, delta, edge, check in cases:
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
app.processEvents()
|
||||
W, H = w.width(), w.height()
|
||||
hit, geo0, geo1 = try_real_drag(w, pos_fn(W, H), delta, edge)
|
||||
assert check(geo0, geo1), \
|
||||
f"{name}: 几何未生效 {geo0.getRect()} → {geo1.getRect()} (命中 {hit})"
|
||||
print(f" ✓ {name:2s} 命中 {hit:10s} → {geo0.width()}x{geo0.height()} "
|
||||
f"→ {geo1.width()}x{geo1.height()}")
|
||||
# BR:原生 QSizeGrip 负责(真实桌面可拖;offscreen 下只验证 grip 存在且不被过滤器拦截)
|
||||
w.resize(800, 600)
|
||||
w.show()
|
||||
app.processEvents()
|
||||
br_child = w.childAt(QPoint(w.width() - 4, w.height() - 4))
|
||||
assert isinstance(br_child, QSizeGrip), f"右下角应为 QSizeGrip, 实际 {type(br_child).__name__}"
|
||||
print(f" ✓ BR 命中 QSizeGrip(原生角拖保留)")
|
||||
|
||||
print("[7/8] 最大化时:真实按压菜单栏顶缘 → 还原(事件过滤器路径)")
|
||||
w.resize(800, 600)
|
||||
w.showMaximized()
|
||||
app.processEvents()
|
||||
assert w.isMaximized()
|
||||
mb = w.menuBar()
|
||||
local = QPoint(mb.width() // 2, 2) # 菜单栏局部坐标,顶缘 6px 带内
|
||||
deliver(mb, QEvent.MouseButtonPress, local,
|
||||
button=Qt.LeftButton, buttons=Qt.LeftButton)
|
||||
app.processEvents()
|
||||
assert not w.isMaximized(), "事件过滤器路径:最大化时按菜单栏顶缘应还原"
|
||||
deliver(mb, QEvent.MouseButtonRelease, local,
|
||||
button=Qt.LeftButton, buttons=Qt.NoButton)
|
||||
assert w._resize_edge == ""
|
||||
print(f" ✓ 菜单栏顶缘按压 → 已还原 ({w.width()}x{w.height()})")
|
||||
|
||||
print("[8/8] 最小尺寸合理性(回归:曾被 MinimumSizeHint 撑到 1121px 高)")
|
||||
assert w.minimumWidth() <= 800, f"最小宽度异常: {w.minimumWidth()}"
|
||||
assert w.minimumHeight() <= 600, f"最小高度异常: {w.minimumHeight()}"
|
||||
w.resize(1280, 800)
|
||||
app.processEvents()
|
||||
assert w.width() == 1280 and w.height() == 800, \
|
||||
f"resize(1280,800) 被 clamp 成 {w.width()}x{w.height()}"
|
||||
# 能缩小到最小尺寸
|
||||
w.resize(760, 480)
|
||||
app.processEvents()
|
||||
assert w.width() == 760 and w.height() == 480, \
|
||||
f"无法缩到下限: {w.width()}x{w.height()}"
|
||||
print(f" ✓ minimumSize={w.minimumWidth()}x{w.minimumHeight()}, "
|
||||
f"resize(1280,800)/resize(760,480) 均精确生效")
|
||||
|
||||
print("\n窗口边缘拖动测试通过 ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
侧栏折叠测试:
|
||||
- 默认状态可见
|
||||
- _toggle_sidebar 后隐藏 + 浮动按钮显示
|
||||
- 再 toggle 恢复,splitter 宽度还原
|
||||
- 菜单 act_toggle_sidebar 同步状态
|
||||
- Ctrl+B 快捷键一次按键只触发一次(回归:曾经双绑定 toggle 两次=没反应)
|
||||
- 浮动按钮用父级局部坐标,贴主窗口左边缘
|
||||
- 折叠状态持久化到 settings,新窗口恢复
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# 隔离配置:不污染真实 ~/.sshclient/settings.json
|
||||
import ui.theme as theme
|
||||
_tmpdir = tempfile.mkdtemp(prefix="sshclient_sidebar_test_")
|
||||
theme.CONFIG_DIR = Path(_tmpdir)
|
||||
theme.SETTINGS_FILE = Path(_tmpdir) / "settings.json"
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
print("[1/7] 默认状态:侧栏可见")
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
w.activateWindow()
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed is False
|
||||
assert w.left_panel.isVisible()
|
||||
assert w.btn_collapse_sidebar.text() == "◀"
|
||||
assert w.btn_show_sidebar.isHidden()
|
||||
print(" ✓ left_panel 可见, btn_collapse_sidebar='◀', btn_show_sidebar 隐藏")
|
||||
|
||||
print("[2/7] 折叠:侧栏隐藏 + 浮动按钮显示")
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed is True
|
||||
assert not w.left_panel.isVisible()
|
||||
assert w.btn_collapse_sidebar.text() == "▶"
|
||||
assert w.btn_show_sidebar.isVisible()
|
||||
# 菜单项同步
|
||||
assert w.act_toggle_sidebar.isChecked()
|
||||
assert "展开" in w.act_toggle_sidebar.text()
|
||||
print(" ✓ left_panel 隐藏, btn_collapse_sidebar='▶', btn_show_sidebar 可见, 菜单项 checked+text 已同步")
|
||||
|
||||
print("[3/7] 展开:恢复 + splitter 宽度还原")
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed is False
|
||||
assert w.left_panel.isVisible()
|
||||
assert w.btn_collapse_sidebar.text() == "◀"
|
||||
assert w.btn_show_sidebar.isHidden()
|
||||
assert not w.act_toggle_sidebar.isChecked()
|
||||
assert "隐藏" in w.act_toggle_sidebar.text()
|
||||
left_w = w.splitter.sizes()[0]
|
||||
assert left_w > 0, f"展开后侧栏宽度应 >0, 实际 {left_w}"
|
||||
print(f" ✓ 恢复原状, splitter 侧栏宽度={left_w}px")
|
||||
|
||||
print("[4/7] 连续折叠/展开多次")
|
||||
# 当前 collapsed=False;第 k 次 toggle 后 collapsed = (k 为奇数)
|
||||
for i in range(5):
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
expected = (i % 2 == 0) # i=0 → 第1次 → True
|
||||
assert w._sidebar_collapsed == expected, \
|
||||
f"第 {i+1} 次切换后应为 {expected}, 实际 {w._sidebar_collapsed}"
|
||||
assert w.left_panel.isVisible() == (not expected)
|
||||
# 5 次后 collapsed=True,再 toggle 一次回到 False
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed is False
|
||||
print(" ✓ 6 次切换状态全部正确")
|
||||
|
||||
print("[5/7] Ctrl+B 快捷键:一次按键只触发一次")
|
||||
before = w._sidebar_collapsed
|
||||
QTest.keyClick(w, Qt.Key_B, Qt.ControlModifier)
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed == (not before), \
|
||||
f"Ctrl+B 未生效(或双绑定触发了两次): before={before}, after={w._sidebar_collapsed}"
|
||||
QTest.keyClick(w, Qt.Key_B, Qt.ControlModifier)
|
||||
app.processEvents()
|
||||
assert w._sidebar_collapsed == before
|
||||
print(f" ✓ 按两次 Ctrl+B: {before} → {not before} → {before}")
|
||||
|
||||
print("[6/7] 浮动按钮位置:父级局部坐标 (4, 100)")
|
||||
if not w._sidebar_collapsed:
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
pos = w.btn_show_sidebar.pos()
|
||||
assert (pos.x(), pos.y()) == (4, 100), \
|
||||
f"浮动按钮位置 ({pos.x()}, {pos.y()}) != (4, 100)"
|
||||
print(f" ✓ 浮动按钮局部坐标 ({pos.x()}, {pos.y()}),窗口移动/缩放都不会飘走")
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
|
||||
print("[7/7] 状态持久化:重启后恢复折叠状态")
|
||||
# 折叠 → 新窗口应恢复为折叠
|
||||
if not w._sidebar_collapsed:
|
||||
w._toggle_sidebar()
|
||||
app.processEvents()
|
||||
assert theme.load_settings()["sidebar_collapsed"] is True
|
||||
w2 = MainWindow()
|
||||
w2.show()
|
||||
app.processEvents()
|
||||
assert w2._sidebar_collapsed is True
|
||||
assert not w2.left_panel.isVisible()
|
||||
assert w2.btn_show_sidebar.isVisible()
|
||||
assert w2.act_toggle_sidebar.isChecked()
|
||||
# 展开 → 再开新窗口应恢复为展开
|
||||
w2._toggle_sidebar()
|
||||
app.processEvents()
|
||||
w3 = MainWindow()
|
||||
w3.show()
|
||||
app.processEvents()
|
||||
assert w3._sidebar_collapsed is False
|
||||
assert w3.left_panel.isVisible()
|
||||
assert w3.btn_show_sidebar.isHidden()
|
||||
print(" ✓ 折叠/展开状态均能跨启动恢复(含菜单项勾选同步)")
|
||||
|
||||
print("\n侧栏折叠测试全部通过 ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+5
-2
@@ -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] 验证对话框")
|
||||
# 主机对话框
|
||||
|
||||
+508
-36
@@ -7,13 +7,14 @@ import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, QSize
|
||||
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
|
||||
@@ -22,7 +23,8 @@ from .workers import ConnectWorker
|
||||
from .widgets import FileBrowser, MonitorPanel, AIChatPanel
|
||||
from .config_dialog import AIConfigDialog
|
||||
from .terminal_panel import TerminalPanel
|
||||
from .theme import get_qss, get_theme, set_theme
|
||||
from .terminal_tab_widget import TerminalTabWidget
|
||||
from .theme import get_qss, get_theme, set_theme, load_settings, save_settings
|
||||
|
||||
|
||||
APP_NAME = "SSHClient"
|
||||
@@ -44,9 +46,44 @@ class MainWindow(QMainWindow):
|
||||
self._build_ui()
|
||||
self._build_menu()
|
||||
self._build_statusbar()
|
||||
self._apply_theme()
|
||||
|
||||
# 菜单栏/状态栏事件过滤器:
|
||||
# 窗口上下边缘分别被 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 构建
|
||||
# ============================================================
|
||||
@@ -58,6 +95,7 @@ class MainWindow(QMainWindow):
|
||||
root.setSpacing(6)
|
||||
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
self.splitter = splitter
|
||||
root.addWidget(splitter)
|
||||
|
||||
# ====== 左侧:主机面板 ======
|
||||
@@ -65,15 +103,33 @@ class MainWindow(QMainWindow):
|
||||
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;")
|
||||
lv.addWidget(host_title)
|
||||
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_list = QListWidget()
|
||||
self.host_list.itemSelectionChanged.connect(self._on_host_selected)
|
||||
self.host_list.itemDoubleClicked.connect(lambda _: self._do_connect())
|
||||
lv.addWidget(self.host_list, 1)
|
||||
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()
|
||||
@@ -105,7 +161,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: 文件浏览
|
||||
@@ -123,6 +179,53 @@ class MainWindow(QMainWindow):
|
||||
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()
|
||||
# 文件
|
||||
@@ -148,6 +251,11 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
@@ -162,44 +270,156 @@ class MainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(f"{APP_NAME} v{APP_VERSION} 就绪")
|
||||
|
||||
# ============================================================
|
||||
# 主机列表
|
||||
# 主机列表(树形 + 分组)
|
||||
# ============================================================
|
||||
def _load_hosts_to_list(self):
|
||||
self.host_list.clear()
|
||||
for h in self.manager.list_hosts():
|
||||
item = QListWidgetItem(f"{h.get('name', h.get('host'))}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}")
|
||||
item.setData(Qt.UserRole, h.get("id"))
|
||||
self.host_list.addItem(item)
|
||||
# 默认选中第一项
|
||||
if self.host_list.count() > 0:
|
||||
self.host_list.setCurrentRow(0)
|
||||
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_list.selectedItems()
|
||||
"""选中主机时切换到对应主机;选中分组不做任何事"""
|
||||
items = self.host_tree.selectedItems()
|
||||
if not items:
|
||||
self.current_host_id = None
|
||||
self.terminal_panel.close_shell()
|
||||
self.terminal_panel._set_status("未选择主机", "#888")
|
||||
self.terminal_panel.bottom_label.setText(
|
||||
"提示: 连接主机后这里会出现真实的 shell 提示符,可直接键入命令"
|
||||
)
|
||||
return
|
||||
host_id = items[0].data(Qt.UserRole)
|
||||
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
|
||||
# 切换主机时关闭旧 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.tabs.setCurrentIndex(0) # 切到终端 Tab 让用户看到
|
||||
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:
|
||||
self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828")
|
||||
# 主机节点
|
||||
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:
|
||||
@@ -214,8 +434,8 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
"""已废弃:旧版单命令输入框的遗留接口,现终端标签自行管理输入。"""
|
||||
return
|
||||
|
||||
# ============================================================
|
||||
# 主机 CRUD
|
||||
@@ -287,10 +507,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)
|
||||
@@ -300,8 +520,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()
|
||||
@@ -313,6 +533,22 @@ class MainWindow(QMainWindow):
|
||||
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"
|
||||
@@ -367,9 +603,245 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
|
||||
+20
-4
@@ -571,20 +571,36 @@ class TerminalPanel(QWidget):
|
||||
self._send_bytes(b"\r")
|
||||
|
||||
def _on_backspace(self):
|
||||
# 发 DEL(\x7f) 给 shell,由 shell 的 readline 处理退格 + 回显(\b \b)。
|
||||
# 这样 shell 在输入行首会忽略退格,不会删除提示符 root@host:~#。
|
||||
# 旧方案是本地 deletePreviousChar(),但 _current_line_start 在提示符
|
||||
# 之前,导致退到输入行首后还能继续删提示符。
|
||||
if self.chan and self._connected:
|
||||
self._send_bytes(b"\x7f")
|
||||
return
|
||||
# 未连接时 fallback:本地删除
|
||||
cursor = self.term.textCursor()
|
||||
if cursor.position() <= self._current_line_start:
|
||||
return # 已到行首,不删
|
||||
# 不能跨行删(保持当前行)
|
||||
if self._is_at_line_start(cursor):
|
||||
return
|
||||
cursor.deletePreviousChar()
|
||||
|
||||
def _on_delete(self):
|
||||
# 发 Delete 转义序列给 shell,由 shell 处理
|
||||
if self.chan and self._connected:
|
||||
self._send_bytes(b"\x1b[3~")
|
||||
return
|
||||
# 未连接时 fallback:本地删除
|
||||
cursor = self.term.textCursor()
|
||||
if cursor.atEnd():
|
||||
return
|
||||
if cursor.columnNumber() == 0 and not cursor.hasSelection():
|
||||
if cursor.position() < self._current_line_start:
|
||||
cursor.setPosition(self._current_line_start)
|
||||
self.term.setTextCursor(cursor)
|
||||
return
|
||||
if cursor.hasSelection():
|
||||
sel_start = cursor.selectionStart()
|
||||
if sel_start < self._current_line_start:
|
||||
return
|
||||
cursor.deleteChar()
|
||||
|
||||
def _history_prev(self):
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
多标签终端容器
|
||||
- 每个标签是一个独立的 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_duplicate = 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_duplicate:
|
||||
self._duplicate_tab(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 _duplicate_tab(self, idx: int):
|
||||
"""复制标签:新建一个同主机的终端标签(独立 shell 通道)"""
|
||||
src_panel = self._panel_at(idx)
|
||||
if src_panel is None:
|
||||
return
|
||||
host_id = self._host_id_for_panel(src_panel)
|
||||
# 决定新标签的初始连接
|
||||
conn = self.manager.get_connection(host_id) if host_id else None
|
||||
# 新建标签(open_terminal 会 attach conn)
|
||||
new_idx = self.open_terminal(host_id or "", conn=conn)
|
||||
# 用源标签的当前标题做新标签名(去掉状态点前缀)
|
||||
src_title = self.tabs.tabText(idx)
|
||||
for prefix in ("⚪ ", "🟢 ", "🔴 ", "🟡 "):
|
||||
if src_title.startswith(prefix):
|
||||
src_title = src_title[len(prefix):]
|
||||
break
|
||||
# 加 " (副本)" 后缀
|
||||
self.tabs.setTabText(new_idx, f"⚪ {src_title} (副本)")
|
||||
self.tabs.setCurrentIndex(new_idx)
|
||||
# 如果已连接,给用户一个小提示:每个副本是独立 shell
|
||||
if conn:
|
||||
self.statusBar_msg = f"已创建副本(独立 shell)" if hasattr(self, "statusBar_msg") else None
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user