From e0d33adb9737573d3d258afb877759cabeb03f20 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Jul 2026 07:34:06 +0800 Subject: [PATCH] feat: collapsible host sidebar (Ctrl+B / view menu / floating restore button) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ◀ 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 --- test_sidebar.py | 136 ++++++++++++++++++++++++++++++++++++++++++++++ ui/main_window.py | 97 +++++++++++++++++++++++++++++++-- 2 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 test_sidebar.py diff --git a/test_sidebar.py b/test_sidebar.py new file mode 100644 index 0000000..91781ac --- /dev/null +++ b/test_sidebar.py @@ -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() diff --git a/ui/main_window.py b/ui/main_window.py index a63696e..e36c198 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -24,7 +24,7 @@ from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .config_dialog import AIConfigDialog from .terminal_panel import TerminalPanel from .terminal_tab_widget import TerminalTabWidget -from .theme import get_qss, get_theme, set_theme +from .theme import get_qss, get_theme, set_theme, load_settings, save_settings APP_NAME = "SSHClient" @@ -46,7 +46,19 @@ class MainWindow(QMainWindow): self._build_ui() self._build_menu() 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' @@ -58,8 +70,13 @@ class MainWindow(QMainWindow): self._dragging_from_max = False # 鼠标光标缓存 self.setMouseTracking(True) + self._load_hosts_to_list() + # 恢复上次会话的侧栏折叠状态 + if load_settings().get("sidebar_collapsed"): + self._toggle_sidebar() + # ============================================================ # UI 构建 # ============================================================ @@ -71,6 +88,7 @@ class MainWindow(QMainWindow): root.setSpacing(6) splitter = QSplitter(Qt.Horizontal) + self.splitter = splitter root.addWidget(splitter) # ====== 左侧:主机面板 ====== @@ -78,10 +96,21 @@ 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_tree = QTreeWidget() self.host_tree.setHeaderHidden(True) @@ -143,6 +172,43 @@ class MainWindow(QMainWindow): splitter.addWidget(self.tabs) 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): menubar = self.menuBar() # 文件 @@ -168,6 +234,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) @@ -346,8 +417,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 @@ -445,6 +516,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"