diff options
| -rw-r--r-- | docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md | 10 | ||||
| -rw-r--r-- | sb_builder/hints_tab.py | 15 | ||||
| -rw-r--r-- | sb_builder/packages.py | 22 | ||||
| -rw-r--r-- | sb_builder/results.py | 6 | ||||
| -rw-r--r-- | sb_builder/ui.py | 46 | ||||
| -rw-r--r-- | tests/test_gui.py | 3 |
6 files changed, 73 insertions, 29 deletions
diff --git a/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md b/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md index 66fb2c1..21a041f 100644 --- a/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md +++ b/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md @@ -149,8 +149,14 @@ fields, not an error. `gpg-preset-passphrase --forget` per keygrip and returns `{"error": "bad passphrase"}`. The passphrase is never put on a command line, in the environment, in a log or on disk. -- To verify in planning: whether a preset passphrase expires with the cache - TTLs or lasts until the agent restarts. The GUI checks state before every +- Found 2026-10-20: the preset passphrase is lost when gpg-agent exits, and + it exits when root's last login session ends. elogind then removes + `/run/user/0` (root does not linger), where the agent's sockets live; the + agent notices its socket is gone and terminates. The GUI's ssh + ControlMaster keeps a session open while sb-builder runs, so the key stays + unlocked as long as the GUI does, and locks about 10 minutes after it + quits (ControlPersist). Accepted as is; `loginctl enable-linger root` + would keep the agent across sessions. The GUI checks state before every real run either way. ### Version diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py index feba94d..071e304 100644 --- a/sb_builder/hints_tab.py +++ b/sb_builder/hints_tab.py @@ -21,14 +21,14 @@ import os from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter -from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, - QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, - QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget) from . import upstream from .dashboard import STATUS -from .packages import chip, load_rows -from .ui import SP, action, enable +from .packages import load_rows +from .ui import SP, action, chip, enable, group_label, search_field STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], @@ -51,12 +51,13 @@ class HintsTab(QWidget): self.checker = upstream.Checker(self) self.checker.done.connect(self._checked) - self.query = QLineEdit(placeholderText="search package") + self.query = search_field("search package") self.query.textChanged.connect(self.refilter) self.chips = {} chips = QHBoxLayout() + chips.addWidget(group_label("state")) for name in STATES: - self.chips[name] = chip(name) + self.chips[name] = chip(name, STATE_COLOR.get(name)) self.chips[name].toggled.connect(self.refilter) chips.addWidget(self.chips[name]) chips.addStretch() diff --git a/sb_builder/packages.py b/sb_builder/packages.py index 30215e0..a4d1f1b 100644 --- a/sb_builder/packages.py +++ b/sb_builder/packages.py @@ -20,13 +20,13 @@ import html from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal from PyQt6.QtGui import QColor, QKeySequence, QShortcut -from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, - QSplitter, QTableView, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QMessageBox, QScrollArea, QSplitter, QTableView, + QVBoxLayout, QWidget) from . import hints as hintlib from .dashboard import STATUS from .fmt import NA, fmt_time -from .ui import SP, action, enable +from .ui import SP, action, chip, enable, group_label, search_field OVERLAYS = ("personal", "pentesting") STATUSES = ("ok", "failed", "never", "outdated") @@ -159,15 +159,6 @@ class Filter(QSortFilterProxyModel): return matches(row, self.terms, self.overlays, self.statuses) -def chip(text): - b = QPushButton(text, checkable=True) - b.setStyleSheet("QPushButton { border: 1px solid palette(mid); border-radius: 10px;" - " padding: 2px 10px; }" - "QPushButton:checked { background: palette(highlight);" - " color: palette(highlighted-text); }") - return b - - def detail_html(row): """The detail pane for one package: facts, dependencies, then its hints.""" view = hintlib.interpret(row["hints"], row["name"], row["info_version"], row["requires"]) @@ -219,13 +210,14 @@ class PackagesTab(QWidget): self.proxy = Filter() self.proxy.setSourceModel(self.model) - self.query = QLineEdit(placeholderText="search (cat: maint: req: status:)") + self.query = search_field("search (cat: maint: req: status:)") self.query.textChanged.connect(self.refilter) self.chips = {} chips = QHBoxLayout() - for group in (("personal", "pentesting", "upstream"), STATUSES): + for label, group in (("overlay", ("personal", "pentesting", "upstream")), ("status", STATUSES)): + chips.addWidget(group_label(label)) for name in group: - self.chips[name] = chip(name) + self.chips[name] = chip(name, STATUS_COLOR.get(name) if group is STATUSES else None) self.chips[name].toggled.connect(self.refilter) chips.addWidget(self.chips[name]) chips.addSpacing(16) diff --git a/sb_builder/results.py b/sb_builder/results.py index 21fdcc3..3c9df82 100644 --- a/sb_builder/results.py +++ b/sb_builder/results.py @@ -26,8 +26,7 @@ from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QSpli from .dashboard import STATUS from .fmt import NA, fmt_time -from .packages import chip -from .ui import SP, action, enable +from .ui import SP, action, chip, enable, group_label KINDS = ("ok", "failed", "bad", "aborted", "removed") ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") @@ -68,8 +67,9 @@ class ResultsTab(QWidget): self.remote = remote self.rows = [] self.viewers = [] - self.chips = {k: chip(k) for k in KINDS} + self.chips = {k: chip(k, STATUS.get(k)) for k in KINDS} bar = QHBoxLayout() + bar.addWidget(group_label("result")) for c in self.chips.values(): c.toggled.connect(self.refilter) bar.addWidget(c) diff --git a/sb_builder/ui.py b/sb_builder/ui.py index 087d1a7..82ddc6d 100644 --- a/sb_builder/ui.py +++ b/sb_builder/ui.py @@ -21,8 +21,9 @@ does, in workflow order) and `view_actions` for the small one (selection and view controls). The main window shows the current tab's lists. """ -from PyQt6.QtGui import QAction, QIcon -from PyQt6.QtWidgets import QStyle +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPalette, QPixmap +from PyQt6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QStyle def action(parent, text, icon, fallback, slot=None, checkable=False, shortcut=None): @@ -44,3 +45,44 @@ def enable(a, reason): SP = QStyle.StandardPixmap + + +# ── filter bars: search field, group labels, chips ────────────────────────── + +CHIP_STYLE = ("QPushButton { background: palette(button); border: 1px solid palette(mid);" + " border-radius: 11px; padding: 3px 12px; }" + "QPushButton:hover { border-color: palette(highlight); }" + "QPushButton:checked { background: palette(highlight);" + " color: palette(highlighted-text); border-color: palette(highlight); }") + + +def chip(text, color=None): + """A filter toggle that reads as a button; color: a dot, the one the table uses.""" + b = QPushButton(text, checkable=True) + b.setStyleSheet(CHIP_STYLE) + if color: + pix = QPixmap(10, 10) + pix.fill(Qt.GlobalColor.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(color)) + p.drawEllipse(0, 0, 10, 10) + p.end() + b.setIcon(QIcon(pix)) + return b + + +def group_label(text): + """The muted name in front of a group of chips.""" + label = QLabel(text + ":") + label.setForegroundRole(QPalette.ColorRole.PlaceholderText) + label.setContentsMargins(4, 0, 6, 0) + return label + + +def search_field(placeholder): + q = QLineEdit(placeholderText=placeholder, clearButtonEnabled=True) + icon = QIcon.fromTheme("edit-find", QApplication.style().standardIcon(SP.SP_FileDialogContentsView)) + q.addAction(icon, QLineEdit.ActionPosition.LeadingPosition) + return q diff --git a/tests/test_gui.py b/tests/test_gui.py index ee568c1..9d3e565 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -370,6 +370,9 @@ class SmokeTest(unittest.TestCase): w.poll_jobs() tab = w.open_packages() self.assertEqual(tab.proxy.rowCount(), 4) + self.assertFalse(tab.chips["failed"].icon().isNull()) # a dot in the table's color + self.assertTrue(tab.chips["personal"].icon().isNull()) + self.assertTrue(tab.query.isClearButtonEnabled()) tab.chips["failed"].setChecked(True) self.assertEqual(tab.proxy.rowCount(), 1) tab.chips["failed"].setChecked(False) |
