Compare commits

..

9 Commits

Author SHA1 Message Date
Your Name c4dd4d8adf fix: top/bottom edge resize broken — menubar ate events + minimumSize clamped to 1121px
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.
2026-07-29 07:49:10 +08:00
Your Name e0d33adb97 feat: collapsible host sidebar (Ctrl+B / view menu / floating restore button)
- ◀ button in sidebar header + '视图→隐藏侧栏 (Ctrl+B)' menu item
- collapsed state persisted to ~/.sshclient/settings.json, restored on startup
- floating '主机' button (theme-aware dark/light) to re-show sidebar
- fix: remove duplicate QShortcut — QShortcut + QAction shortcut on same key
  both fired, toggling twice per Ctrl+B press (net zero effect)
- fix: floating button positioned with parent-local coords instead of
  mapToGlobal — button no longer drifts off the window edge when the
  window is not at screen origin; resizeEvent/moveEvent overrides removed
- remove dead terminal_input_set_enabled (referenced nonexistent cmd_input)
- test_sidebar.py: 7-step coverage incl. single-fire shortcut regression,
  splitter width restore, cross-restart persistence; settings isolated to tmpdir
2026-07-29 07:34:06 +08:00
Hermes 9c08d7593d feat: window edge drag-to-resize (normal + from maximized)
User asked: '窗口应该可以前后左右拉'

Added custom edge drag handling in MainWindow (QMainWindow's default
resize support exists but ignored + we want full control for
maximized state too):

New methods in ui/main_window.py:
- _hit_test_edge(pos) -> 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'/''
  6px margin around window. Returns which edge (or corner) the
  cursor is on. Returns '' when maximized/fullscreen.
- _edge_to_cursor(edge): sets Qt.SizeHor/Ver/FDiag/BDiag cursor
- _do_resize(global_pos): computes new geometry based on drag
  delta. Respects self.minimumWidth/Height (QMainWindow's auto
  min size from menu/toolbar/statusbar) and screen bounds.
- _restore_from_max(global_pos): when maximized, click+drag on
  TOP edge → showNormal() + set geometry to mouse-relative size
  + continue drag from 'TL' corner (like Windows native behavior)
- mousePressEvent/Move/Release: hook into resize state
- leaveEvent: clear cursor when leaving window
- changeEvent: clear resize state on window state change

State fields added in __init__:
- self._resize_edge, self._resize_start_geo, self._resize_start_pos
- self._resize_margin = 6
- self._dragging_from_max = False

Fix: 'QTabWidget.RightSide' was wrong API; used 'QTabBar.RightSide'
in the multi-tab commit (unrelated, but I noticed while testing).

New test_resize.py (5 tests, all pass):
- 8-region hit-test (4 edges + 4 corners)
- Right-edge drag → width grows
- Left-edge drag → x moves right (width limited by minW)
- Top-left corner drag
- Maximized + click top edge → restored to normal

Existing tests (core 6/6, UI 3/3) still pass.

Build: 57MB exe.
2026-07-29 07:23:16 +08:00
Hermes a98f22202d feat: add '复制标签' option to terminal tab right-click menu
User asked: '标签使用鼠标右键应该有复制标签的功能'

In ui/terminal_tab_widget.py _on_tab_context_menu:
- Added '📋 复制标签' action between '重命名' and '关闭标签'
- New method _duplicate_tab(idx): creates a new terminal tab for
  the same host (reuses SSH connection, opens independent shell
  channel via paramiko invoke_shell)
- New tab title = '<src_title> (副本)' with  state dot
- If host not connected: duplicate creates a placeholder tab
- If host connected: duplicate immediately opens a new shell

Verified: 3 tabs after duplicating 'test-server' (欢迎 +  test-server
+  test-server (副本))

All tests still pass.
2026-07-29 07:12:52 +08:00
Hermes 2c6f06e392 feat: multi-tab terminal (Xshell-style)
User asked: '终端应该支持多标签功能,不能只支持一个'

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

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

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

Verified: 4 tabs visible (欢迎 + 3 open),  button works, closeable.
Tests: core 6/6 + UI 3/3 pass.
2026-07-29 07:05:59 +08:00
Your Name 47c6589e1d feat: application icon for Windows taskbar + exe
- scripts/gen_icon.py: generates 256x256 SSH client icon (dark terminal
  window with >_ prompt and SSH connection nodes), outputs multi-size
  ICO (16-256px) + PNG
- resources/icon.ico + icon.png: generated icon files
- main.py: _get_icon_path() searches _MEIPASS (onefile), exe dir, source
  dir; sets app + window icon
- build.spec: bundles icon files via datas=, sets exe icon=icon.ico
- Windows taskbar will now show the custom icon instead of default PyInstaller icon
2026-07-29 06:54:12 +08:00
Your Name acaa48b242 feat: host groups - tree view with folders, drag-free context menu
Data model:
- hosts.json upgraded to {groups: [...], hosts: [...]}, auto-migrates
  old flat-list format on load
- each host gets group field (default '' = ungrouped)

Manager API (core/manager.py):
- add_group / remove_group / rename_group / move_host_to_group
- list_groups / list_hosts_by_group

UI (ui/main_window.py):
- QListWidget replaced with QTreeWidget (folder tree)
- Group nodes: 📁 name (count), expandable/collapsible
- Ungrouped always shown as 📭 未分组
- Right-click context menu:
  - Empty area / group: New group, Rename, Delete
  - Host: Connect, Edit, Delete, Move to group (submenu)
- Double-click host = connect, double-click group = expand/collapse
- All 6 test suites pass
2026-07-29 06:51:23 +08:00
Your Name ac29515d9f fix: Backspace/Delete deleting prompt text (root@host:~#)
Root cause: _current_line_start was set after \r\n but BEFORE the prompt
was rendered, so Backspace could erase prompt characters after reaching
the input line start.

Fix: send \x7f (DEL) to shell on Backspace and \x1b[3~ on Delete instead
of locally deleting characters. The shell's readline handles backspace
correctly - it ignores DEL at the input line start, so the prompt is
never touched. The existing \b handler in _on_data already correctly
processes the shell's \b-space-\b echo sequence.

Verified: typing 'abc' + 3 Backspace deletes input, 5 more Backspace
leaves prompt 'root@host:~# ' intact.
2026-07-29 06:47:08 +08:00
Your Name f0b6a71a65 fix: Delete key in terminal deleting prompt text (root@host)
_on_delete() was missing _current_line_start check that _on_backspace
already had. Clicking into the prompt area (root@hostname:~#) and pressing
Delete would erase prompt characters. Now:
- Cursor before input line: moved to input start, no deletion
- Selection crossing prompt boundary: blocked
2026-07-29 06:40:37 +08:00
14 changed files with 1560 additions and 50 deletions
+3 -3
View File
@@ -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
View File
@@ -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:
+32 -1
View File
@@ -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

+130
View File
@@ -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})")
# 生成多尺寸 ICOPillow 自动从 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 时已 grabMousemove 持续走这里
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
View File
@@ -571,19 +571,35 @@ 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()
+398
View File
@@ -0,0 +1,398 @@
"""
多标签终端容器
- 每个标签是一个独立的 TerminalPanel(独立 shell、独立历史)
- 标签栏左侧 按钮弹主机选择下拉
- 标签可关闭(×),关闭时如有活跃 shell 弹确认
- 标签双击重命名
- 标签标题显示:主机别名(连接状态点 + 别名)
- 兼容老 APIattach / close_shell / _set_status(操作当前激活标签)
- 新 APIopen_terminal(host_id, conn, label) / close_current_tab() /
current_host_idproperty
"""
import sys
from typing import Optional, List
from PyQt5.QtCore import Qt, pyqtSignal, QPoint
from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QPushButton, QLabel,
QMenu, QInputDialog, QMessageBox, QToolButton, QApplication, QListWidget,
QListWidgetItem, QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QComboBox,
)
from core.ssh_client import SSHConnection
from core.manager import ConnectionManager
from .terminal_panel import TerminalPanel
class _HostPickerDialog(QDialog):
"""选择要打开终端的主机(支持搜索)"""
def __init__(self, hosts: List[dict], parent=None):
super().__init__(parent)
self.setWindowTitle("选择主机")
self.resize(380, 400)
self.hosts = hosts
self.selected_host_id: Optional[str] = None
self._build()
def _build(self):
v = QVBoxLayout(self)
self.search = QLineEdit()
self.search.setPlaceholderText("🔍 搜索主机名/地址/用户名...")
self.search.textChanged.connect(self._refresh)
v.addWidget(self.search)
self.listw = QListWidget()
self.listw.itemDoubleClicked.connect(self._on_double_clicked)
v.addWidget(self.listw, 1)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self._on_ok)
bb.rejected.connect(self.reject)
v.addWidget(bb)
self._refresh()
self.search.setFocus()
def _refresh(self):
q = self.search.text().strip().lower()
self.listw.clear()
for h in self.hosts:
hay = f"{h.get('name','')} {h.get('host','')} {h.get('username','')}".lower()
if q and q not in hay:
continue
display = f"{h.get('name', h.get('host'))} · {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}"
item = QListWidgetItem(display)
item.setData(Qt.UserRole, h.get("id"))
self.listw.addItem(item)
if self.listw.count() > 0:
self.listw.setCurrentRow(0)
def _on_double_clicked(self, _item):
self._on_ok()
def _on_ok(self):
item = self.listw.currentItem()
if not item:
QMessageBox.information(self, "提示", "请选择一台主机")
return
self.selected_host_id = item.data(Qt.UserRole)
self.accept()
class TerminalTabWidget(QWidget):
"""多标签终端容器"""
# 标签关闭时通知主窗口
terminal_tab_closed = pyqtSignal()
# 当前激活标签变化
current_terminal_changed = pyqtSignal(str) # host_id
def __init__(self, manager: ConnectionManager, parent=None):
super().__init__(parent)
self.manager = manager
self._tabs_by_host: dict = {} # host_id -> (tab_index, TerminalPanel)
self._build()
def _build(self):
v = QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.tabs = QTabWidget()
self.tabs.setTabsClosable(True)
self.tabs.setMovable(True)
self.tabs.setDocumentMode(True)
# 标签栏左侧按钮:新建 + 下拉
self.btn_new = QToolButton()
self.btn_new.setText("")
self.btn_new.setToolTip("新建终端标签(选择主机)")
self.btn_new.setPopupMode(QToolButton.InstantPopup)
self.btn_new.setFixedWidth(28)
# 用菜单代替 popup
new_menu = QMenu(self.btn_new)
new_menu.addAction("📡 新建终端(选择主机)", self._on_new_from_picker)
new_menu.addSeparator()
new_menu.addAction(" 新建空白标签", self._on_new_blank)
self.btn_new.setMenu(new_menu)
# 加到 tab 栏最左
self.tabs.setCornerWidget(self.btn_new, Qt.TopLeftCorner)
# 标签页右键菜单
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu)
# 关闭按钮
self.tabs.tabCloseRequested.connect(self._on_close_requested)
# 切换标签
self.tabs.currentChanged.connect(self._on_current_changed)
# 双击标签重命名
self.tabs.tabBar().tabBarDoubleClicked.connect(self._on_tab_double_clicked)
v.addWidget(self.tabs, 1)
# 初始空白页
self._add_placeholder()
# ============================================================
# 标签管理
# ============================================================
def _add_placeholder(self):
"""初始占位页(提示用户点 新建)"""
w = QWidget()
layout = QVBoxLayout(w)
layout.setAlignment(Qt.AlignCenter)
hint = QLabel(
"👋 点击左上角 按钮新建终端\n\n"
"或选中左侧主机 → 双击连接 → 自动创建终端标签"
)
hint.setAlignment(Qt.AlignCenter)
hint.setStyleSheet("color: #888; font-size: 12pt;")
layout.addWidget(hint)
idx = self.tabs.addTab(w, "📡 欢迎")
self.tabs.setTabToolTip(idx, "新建终端开始使用")
# 占位页不可关闭
from PyQt5.QtWidgets import QTabBar
self.tabs.tabBar().setTabButton(idx, QTabBar.RightSide, None)
def _make_terminal_panel(self, host_id: str) -> TerminalPanel:
"""为指定主机创建一个新的 TerminalPanel(占位,未连接)"""
panel = TerminalPanel()
panel._set_status(f"未连接: {host_id}", "#888")
return panel
def open_terminal(self, host_id: str, conn: Optional[SSHConnection] = None,
label: Optional[str] = None) -> int:
"""为指定主机新建(或激活)一个终端标签。
同一主机已有标签则激活并复用(按需求 B:同主机可多开 → 这里改为总是新建)
返回新建标签的 index。
"""
# 需求是"同主机可多开"——所以总是新建
host_info = self.manager.get_host(host_id) if host_id else None
title = label or (host_info.get("name") if host_info else host_id) or "Shell"
# 标题 + 状态点(默认 ⚪)
tab_title = f"{title}"
panel = self._make_terminal_panel(host_id or "")
idx = self.tabs.addTab(panel, tab_title)
self.tabs.setTabToolTip(idx, f"主机: {host_id}")
# 记录映射
self._tabs_by_host.setdefault(host_id or f"__adhoc_{idx}", []).append((idx, panel))
self.tabs.setCurrentIndex(idx)
if conn:
panel.attach(conn)
self._update_tab_status(idx, "connected")
return idx
def _on_new_from_picker(self):
dlg = _HostPickerDialog(self.manager.list_hosts(), self)
if dlg.exec_() != dlg.Accepted:
return
host_id = dlg.selected_host_id
if not host_id:
return
conn = self.manager.get_connection(host_id)
if not conn or not conn.connected:
# 没连接:先建占位标签,用户自己去连接
self.open_terminal(host_id, conn=None)
QMessageBox.information(
self, "提示",
f"已为主机「{self.manager.get_host(host_id).get('name', host_id)}」创建终端标签。\n"
"请在左侧主机列表点 🔌 连接。"
)
else:
self.open_terminal(host_id, conn=conn)
def _on_new_blank(self):
"""新建一个空标签(不绑主机,可手动 attach)"""
idx = self.tabs.addTab(self._make_terminal_panel(""), "⚪ Shell")
self.tabs.setCurrentIndex(idx)
def _on_close_requested(self, idx: int):
self._close_tab(idx)
def _on_current_changed(self, idx: int):
if idx < 0:
self.current_terminal_changed.emit("")
return
panel = self._panel_at(idx)
if panel:
hid = self._host_id_for_panel(panel)
self.current_terminal_changed.emit(hid)
def _on_tab_double_clicked(self, idx: int):
if idx < 0 or idx >= self.tabs.count():
return
cur = self.tabs.tabText(idx)
new, ok = QInputDialog.getText(self, "重命名标签", "标签名:", text=cur)
if ok and new.strip():
self.tabs.setTabText(idx, new.strip())
def _on_tab_context_menu(self, pos: QPoint):
idx = self.tabs.tabBar().tabAt(pos)
if idx < 0:
return
menu = QMenu(self)
panel = self._panel_at(idx)
a_rename = menu.addAction("重命名")
a_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)
# ============================================================
# 兼容老 APImain_window 还在用)
# ============================================================
def _current_panel(self) -> Optional[TerminalPanel]:
idx = self.tabs.currentIndex()
if idx < 0:
return None
w = self.tabs.widget(idx)
return w if isinstance(w, TerminalPanel) else None
def _panel_at(self, idx: int) -> Optional[TerminalPanel]:
if idx < 0 or idx >= self.tabs.count():
return None
w = self.tabs.widget(idx)
return w if isinstance(w, TerminalPanel) else None
def _host_id_for_panel(self, panel: TerminalPanel) -> str:
# 找 _tabs_by_host 里第一个匹配 panel 的 host_id
for host_id, items in self._tabs_by_host.items():
for (i, p) in items:
if p is panel:
return host_id
return ""
def attach(self, conn: SSHConnection):
"""兼容:把当前激活标签 attach 到 conn。
如果当前标签不是 TerminalPanel 或没绑 host,新建一个标签。
"""
cur = self._current_panel()
if cur is None:
# 当前是占位页 → 直接新建
host_id = self._find_host_id_by_conn(conn)
idx = self.open_terminal(host_id or "Shell", conn=conn)
else:
cur.attach(conn)
host_id = self._find_host_id_by_conn(conn)
self._update_tab_status_by_panel(cur, "connected")
self.tabs.setCurrentIndex(0)
def close_shell(self):
cur = self._current_panel()
if cur:
cur.close_shell()
self._update_tab_status_by_panel(cur, "disconnected")
def _set_status(self, text: str, color: str = "#888"):
cur = self._current_panel()
if cur:
cur._set_status(text, color)
@property
def current_host_id(self) -> str:
cur = self._current_panel()
return self._host_id_for_panel(cur) if cur else ""
def _find_host_id_by_conn(self, conn: SSHConnection) -> str:
for h in self.manager.list_hosts():
c = self.manager.get_connection(h.get("id"))
if c is conn:
return h.get("id")
return ""
def _update_tab_status(self, idx: int, state: str):
"""更新标签标题:⚪未连接 / 🟢已连接 / 🔴已断开"""
title = self.tabs.tabText(idx)
# 去掉已有状态点
for prefix in ("", "🟢 ", "🔴 ", "🟡 "):
if title.startswith(prefix):
title = title[len(prefix):]
break
if state == "connected":
new_title = f"🟢 {title}"
elif state == "disconnected":
new_title = f"🔴 {title}"
elif state == "error":
new_title = f"🟡 {title}"
else:
new_title = f"{title}"
self.tabs.setTabText(idx, new_title)
def _update_tab_status_by_panel(self, panel: TerminalPanel, state: str):
for i in range(self.tabs.count()):
if self.tabs.widget(i) is panel:
self._update_tab_status(i, state)
return
def shutdown(self):
"""主窗口关闭时:关闭所有标签的 shell"""
for i in range(self.tabs.count()):
w = self.tabs.widget(i)
if isinstance(w, TerminalPanel):
w.close_shell()