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
This commit is contained in:
+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()
|
||||||
+92
-5
@@ -24,7 +24,7 @@ from .widgets import FileBrowser, MonitorPanel, AIChatPanel
|
|||||||
from .config_dialog import AIConfigDialog
|
from .config_dialog import AIConfigDialog
|
||||||
from .terminal_panel import TerminalPanel
|
from .terminal_panel import TerminalPanel
|
||||||
from .terminal_tab_widget import TerminalTabWidget
|
from .terminal_tab_widget import TerminalTabWidget
|
||||||
from .theme import get_qss, get_theme, set_theme
|
from .theme import get_qss, get_theme, set_theme, load_settings, save_settings
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "SSHClient"
|
APP_NAME = "SSHClient"
|
||||||
@@ -46,7 +46,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
self._build_statusbar()
|
self._build_statusbar()
|
||||||
self._apply_theme()
|
|
||||||
|
# 左侧主机栏折叠状态
|
||||||
|
# 注意: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_edge: str = "" # '' / 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'
|
||||||
@@ -58,8 +70,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._dragging_from_max = False
|
self._dragging_from_max = False
|
||||||
# 鼠标光标缓存
|
# 鼠标光标缓存
|
||||||
self.setMouseTracking(True)
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
self._load_hosts_to_list()
|
self._load_hosts_to_list()
|
||||||
|
|
||||||
|
# 恢复上次会话的侧栏折叠状态
|
||||||
|
if load_settings().get("sidebar_collapsed"):
|
||||||
|
self._toggle_sidebar()
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# UI 构建
|
# UI 构建
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -71,6 +88,7 @@ class MainWindow(QMainWindow):
|
|||||||
root.setSpacing(6)
|
root.setSpacing(6)
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Horizontal)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.splitter = splitter
|
||||||
root.addWidget(splitter)
|
root.addWidget(splitter)
|
||||||
|
|
||||||
# ====== 左侧:主机面板 ======
|
# ====== 左侧:主机面板 ======
|
||||||
@@ -78,10 +96,21 @@ class MainWindow(QMainWindow):
|
|||||||
lv = QVBoxLayout(left)
|
lv = QVBoxLayout(left)
|
||||||
lv.setContentsMargins(4, 4, 4, 4)
|
lv.setContentsMargins(4, 4, 4, 4)
|
||||||
lv.setSpacing(6)
|
lv.setSpacing(6)
|
||||||
|
self.left_panel = left # 保存引用供折叠用
|
||||||
|
|
||||||
|
# 标题栏(带折叠按钮)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.setContentsMargins(0, 0, 0, 0)
|
||||||
host_title = QLabel("🖥 主机")
|
host_title = QLabel("🖥 主机")
|
||||||
host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;")
|
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_tree = QTreeWidget()
|
self.host_tree = QTreeWidget()
|
||||||
self.host_tree.setHeaderHidden(True)
|
self.host_tree.setHeaderHidden(True)
|
||||||
@@ -143,6 +172,43 @@ class MainWindow(QMainWindow):
|
|||||||
splitter.addWidget(self.tabs)
|
splitter.addWidget(self.tabs)
|
||||||
splitter.setSizes([280, 1000])
|
splitter.setSizes([280, 1000])
|
||||||
|
|
||||||
|
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):
|
def _build_menu(self):
|
||||||
menubar = self.menuBar()
|
menubar = self.menuBar()
|
||||||
# 文件
|
# 文件
|
||||||
@@ -168,6 +234,11 @@ class MainWindow(QMainWindow):
|
|||||||
m_ai.addAction(act_clear)
|
m_ai.addAction(act_clear)
|
||||||
# 视图
|
# 视图
|
||||||
m_view = menubar.addMenu("视图(&V)")
|
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 = QAction("🌙 暗色主题", self, checkable=True)
|
||||||
self.act_dark.setChecked(get_theme() == "dark")
|
self.act_dark.setChecked(get_theme() == "dark")
|
||||||
self.act_dark.triggered.connect(self._toggle_theme)
|
self.act_dark.triggered.connect(self._toggle_theme)
|
||||||
@@ -346,8 +417,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
|
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
|
||||||
|
|
||||||
def terminal_input_set_enabled(self, enabled: bool):
|
def terminal_input_set_enabled(self, enabled: bool):
|
||||||
self.cmd_input.setEnabled(enabled)
|
"""已废弃:旧版单命令输入框的遗留接口,现终端标签自行管理输入。"""
|
||||||
self.btn_run.setEnabled(enabled)
|
return
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 主机 CRUD
|
# 主机 CRUD
|
||||||
@@ -445,6 +516,22 @@ class MainWindow(QMainWindow):
|
|||||||
theme = get_theme()
|
theme = get_theme()
|
||||||
qss = get_qss(theme)
|
qss = get_qss(theme)
|
||||||
QApplication.instance().setStyleSheet(qss)
|
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):
|
def _toggle_theme(self):
|
||||||
new_theme = "dark" if self.act_dark.isChecked() else "light"
|
new_theme = "dark" if self.act_dark.isChecked() else "light"
|
||||||
|
|||||||
Reference in New Issue
Block a user