feat: command snippets + UI test coverage for new features

- core/snippets.py: SnippetManager with 20 prebuilt sysadmin commands,
  CRUD + reorder, persisted to ~/.sshclient/snippets.json
- ui/snippet_dialog.py: SnippetDialog (add/edit) + SnippetManagerDialog
  (table view with add/edit/delete/move up/down)
- ui/terminal_panel.py: snippet combo box in toolbar, send_command()
  method to inject commands into interactive shell, snippet manager button
- README.md: updated feature table, usage section, project structure
- test_ui.py: added [2/4] checks for sparkline/snippets/theme,
  added [4/4] theme toggle verification
This commit is contained in:
Your Name
2026-07-28 23:03:37 +08:00
parent 57092cad03
commit 2b29a5cf48
5 changed files with 341 additions and 11 deletions
+151
View File
@@ -0,0 +1,151 @@
"""
命令片段管理对话框
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QLabel, QLineEdit, QTextEdit, QMessageBox, QHeaderView,
QAbstractItemView, QSplitter, QWidget, QFormLayout,
)
from core.snippets import SnippetManager
class SnippetDialog(QDialog):
"""单个片段的编辑/新增对话框"""
def __init__(self, parent=None, name: str = "", cmd: str = ""):
super().__init__(parent)
self.setWindowTitle("编辑片段" if name else "新增片段")
self.setMinimumWidth(460)
v = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit(name)
self.name_edit.setPlaceholderText("如:查看端口占用")
form.addRow("名称:", self.name_edit)
self.cmd_edit = QTextEdit(cmd)
self.cmd_edit.setPlaceholderText("如:ss -tlnp | grep :80")
self.cmd_edit.setMaximumHeight(80)
form.addRow("命令:", self.cmd_edit)
v.addLayout(form)
btns = QHBoxLayout()
btns.addStretch(1)
ok = QPushButton("保存")
ok.clicked.connect(self._on_ok)
cancel = QPushButton("取消")
cancel.clicked.connect(self.reject)
btns.addWidget(ok)
btns.addWidget(cancel)
v.addLayout(btns)
def _on_ok(self):
if not self.name_edit.text().strip():
QMessageBox.warning(self, "提示", "请输入名称")
return
if not self.cmd_edit.toPlainText().strip():
QMessageBox.warning(self, "提示", "请输入命令")
return
self.accept()
def get_value(self) -> dict:
return {
"name": self.name_edit.text().strip(),
"cmd": self.cmd_edit.toPlainText().strip(),
}
class SnippetManagerDialog(QDialog):
"""片段管理主对话框:表格 + 增删改 + 上下移"""
def __init__(self, mgr: SnippetManager, parent=None):
super().__init__(parent)
self.mgr = mgr
self.setWindowTitle("命令片段管理")
self.setMinimumSize(520, 420)
self._build_ui()
self._refresh_table()
def _build_ui(self):
v = QVBoxLayout(self)
# 工具栏
toolbar = QHBoxLayout()
toolbar.addWidget(QLabel("📋 命令片段列表"))
toolbar.addStretch(1)
self.btn_add = QPushButton(" 新增")
self.btn_add.clicked.connect(self._add)
self.btn_edit = QPushButton("✏ 编辑")
self.btn_edit.clicked.connect(self._edit)
self.btn_del = QPushButton("🗑 删除")
self.btn_del.clicked.connect(self._delete)
self.btn_up = QPushButton("⬆ 上移")
self.btn_up.clicked.connect(lambda: self._move(-1))
self.btn_down = QPushButton("⬇ 下移")
self.btn_down.clicked.connect(lambda: self._move(1))
for b in (self.btn_add, self.btn_edit, self.btn_del, self.btn_up, self.btn_down):
toolbar.addWidget(b)
v.addLayout(toolbar)
# 表格
self.table = QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(["名称", "命令"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.doubleClicked.connect(self._edit)
v.addWidget(self.table)
# 关闭按钮
btns = QHBoxLayout()
btns.addStretch(1)
close = QPushButton("关闭")
close.clicked.connect(self.accept)
btns.addWidget(close)
v.addLayout(btns)
def _refresh_table(self):
snippets = self.mgr.list_all()
self.table.setRowCount(len(snippets))
for i, s in enumerate(snippets):
self.table.setItem(i, 0, QTableWidgetItem(s["name"]))
cmd_display = s["cmd"][:60] + ("..." if len(s["cmd"]) > 60 else "")
self.table.setItem(i, 1, QTableWidgetItem(cmd_display))
self.table.item(i, 0).setToolTip(s["cmd"])
def _add(self):
dlg = SnippetDialog(self)
if dlg.exec_() == dlg.Accepted:
v = dlg.get_value()
self.mgr.add(v["name"], v["cmd"])
self._refresh_table()
def _edit(self):
row = self.table.currentRow()
if row < 0:
return
s = self.mgr.list_all()[row]
dlg = SnippetDialog(self, s["name"], s["cmd"])
if dlg.exec_() == dlg.Accepted:
v = dlg.get_value()
self.mgr.update(row, v["name"], v["cmd"])
self._refresh_table()
def _delete(self):
row = self.table.currentRow()
if row < 0:
return
s = self.mgr.list_all()[row]
if QMessageBox.question(
self, "确认删除", f"删除片段「{s['name']}」?",
QMessageBox.Yes | QMessageBox.No
) == QMessageBox.Yes:
self.mgr.remove(row)
self._refresh_table()
def _move(self, direction):
row = self.table.currentRow()
if row < 0:
return
self.mgr.move(row, direction)
new_row = row + direction
self._refresh_table()
if 0 <= new_row < self.table.rowCount():
self.table.selectRow(new_row)
+62
View File
@@ -21,6 +21,7 @@ from PyQt5.QtWidgets import (
)
from core.ssh_client import SSHConnection
from core.snippets import SnippetManager
# ANSI 颜色映射(16 色)
@@ -133,6 +134,8 @@ class TerminalPanel(QWidget):
self._ansi_invert = False
# OSC 0/2 设置的窗口标题
self._window_title = ""
# 命令片段
self.snippet_mgr = SnippetManager()
self._build_ui()
self._apply_style()
@@ -152,6 +155,20 @@ class TerminalPanel(QWidget):
self.status_label.setStyleSheet("color: #888;")
toolbar.addWidget(self.status_label)
toolbar.addStretch(1)
# 命令片段
toolbar.addWidget(QLabel("📋"))
self.snippet_combo = QComboBox()
self.snippet_combo.setMinimumWidth(180)
self.snippet_combo.setToolTip("选择命令片段快速发送到终端")
self.snippet_combo.currentIndexChanged.connect(self._on_snippet_selected)
toolbar.addWidget(self.snippet_combo)
self._refresh_snippets()
self.btn_snippet_mgr = QPushButton("")
self.btn_snippet_mgr.setFixedWidth(28)
self.btn_snippet_mgr.setToolTip("管理命令片段")
self.btn_snippet_mgr.clicked.connect(self._open_snippet_manager)
toolbar.addWidget(self.btn_snippet_mgr)
toolbar.addSpacing(8)
self.btn_clear = QPushButton("清屏")
self.btn_clear.clicked.connect(self._clear_screen)
self.btn_reset = QPushButton("重连 shell")
@@ -190,6 +207,51 @@ class TerminalPanel(QWidget):
}
""")
# ============================================================
# 命令片段
# ============================================================
def _refresh_snippets(self):
"""刷新下拉框"""
self.snippet_combo.blockSignals(True)
self.snippet_combo.clear()
self.snippet_combo.addItem("-- 选择命令片段 --", "")
for s in self.snippet_mgr.list_all():
self.snippet_combo.addItem(f"{s['name']} ({s['cmd'][:30]})", s["cmd"])
self.snippet_combo.blockSignals(False)
def _on_snippet_selected(self, index: int):
"""选择片段后发送命令到终端"""
if index <= 0:
return
cmd = self.snippet_combo.itemData(index)
if not cmd:
return
# 重置下拉框选中项(让用户能重复选同一个)
self.snippet_combo.blockSignals(True)
self.snippet_combo.setCurrentIndex(0)
self.snippet_combo.blockSignals(False)
# 发送命令
self.send_command(cmd)
def send_command(self, cmd: str):
"""发送一条命令到远程 shell(自动加换行)"""
if not self.chan or not self._connected:
self._set_status("未连接,无法发送命令", "#c62828")
return
data = cmd.encode("utf-8", errors="replace")
if not cmd.endswith("\n"):
data += b"\n"
try:
self.chan.send(data)
except Exception as e:
self._set_status(f"发送失败: {e}", "#c62828")
def _open_snippet_manager(self):
from .snippet_dialog import SnippetManagerDialog
dlg = SnippetManagerDialog(self.snippet_mgr, self)
dlg.exec_()
self._refresh_snippets()
# ============================================================
# 生命周期
# ============================================================