aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 11:16:23 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 11:16:23 +0200
commitbafab248a348e861511c8a984bdafa48fc32215f (patch)
treec67d14bb0b32464676f38e43c3f6e1abd4ef9dcd
parent2989606c3c0bcf08e99468067b0ab73c6810aa1c (diff)
downloadsb-builder-bafab248a348e861511c8a984bdafa48fc32215f.tar.gz
sb-builder-bafab248a348e861511c8a984bdafa48fc32215f.zip
Hints screen: upstream check, hint jobs, diff and revert
Check upstream fills the upstream column and ticks the outdated hints, Update selected starts hint-update, Delete hint acts on the VM. When a hint job ends its SBB-RESULT opens a dialog with each changed hint's diff, Revert per package (once) and Build these. The Packages detail pane gets New hint and Open in Hints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--sb_builder/hints_tab.py277
-rw-r--r--sb_builder/mainwindow.py45
-rw-r--r--sb_builder/packages.py53
-rw-r--r--tests/test_gui.py71
4 files changed, 438 insertions, 8 deletions
diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py
new file mode 100644
index 0000000..6be57fc
--- /dev/null
+++ b/sb_builder/hints_tab.py
@@ -0,0 +1,277 @@
+# sb-builder - Hints tab
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Hints tab: upstream check, hint jobs, and the diff/revert dialog after them."""
+
+import difflib
+import os
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QColor, QFontDatabase
+from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter,
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
+
+from . import upstream
+from .dashboard import STATUS
+from .packages import chip, load_rows
+
+STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error")
+STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"],
+ "nvchecker error": STATUS["failed"]}
+COLUMNS = ("", "package", "hint", ".info", "upstream", "state")
+HINT_JOBS = ("hint-update", "hint-new")
+
+
+class HintsTab(QWidget):
+ start_requested = pyqtSignal(list) # job-start arguments
+
+ def __init__(self, remote, cfg, stanzas=upstream.STANZAS, parent=None):
+ super().__init__(parent)
+ self.remote, self.cfg, self.stanzas = remote, cfg, stanzas
+ self.rows = []
+ self.names = upstream.stanzas(stanzas)
+ self.versions = None # {pkg: version} after Check upstream
+ self.errors, self.failure = {}, ""
+ self.checker = upstream.Checker(self)
+ self.checker.done.connect(self._checked)
+
+ self.query = QLineEdit(placeholderText="search package")
+ self.query.textChanged.connect(self.refilter)
+ self.chips = {}
+ chips = QHBoxLayout()
+ for name in STATES:
+ self.chips[name] = chip(name)
+ self.chips[name].toggled.connect(self.refilter)
+ chips.addWidget(self.chips[name])
+ chips.addStretch()
+ self.refresh_btn = QPushButton("Refresh")
+ self.refresh_btn.clicked.connect(self.reload)
+ chips.addWidget(self.refresh_btn)
+
+ self.table = QTableWidget(0, len(COLUMNS))
+ self.table.setHorizontalHeaderLabels(COLUMNS)
+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.table.verticalHeader().hide()
+ self.table.horizontalHeader().setStretchLastSection(True)
+ for col, width in enumerate((28, 220, 110, 110, 110)):
+ self.table.setColumnWidth(col, width)
+
+ self.status = QLabel()
+ self.check_btn = QPushButton("Check upstream")
+ self.check_btn.clicked.connect(self.check)
+ self.update_btn = QPushButton("Update selected")
+ self.update_btn.clicked.connect(self.update_selected)
+ self.no_dl = QCheckBox("no download")
+ self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache')
+ bar = QHBoxLayout()
+ bar.addWidget(self.status)
+ bar.addStretch()
+ for w in (self.check_btn, self.update_btn, self.no_dl):
+ bar.addWidget(w)
+ delete = QPushButton("Delete hint")
+ delete.clicked.connect(self.delete)
+ bar.addWidget(delete)
+
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.query)
+ layout.addLayout(chips)
+ layout.addWidget(self.table)
+ layout.addLayout(bar)
+
+ def reload(self):
+ self.status.setText("loading…")
+ self.remote.call(self._loaded, "inventory")
+
+ def _loaded(self, data, err):
+ if err:
+ self.status.setText(f"inventory failed: {err}")
+ return
+ self.names = upstream.stanzas(self.stanzas) # re-read: the file is edited by hand
+ self.rows = [r for r in load_rows(data) if r["hint_path"] or r["name"] in self.names]
+ self._fill()
+
+ def _fill(self, check_outdated=False):
+ checked = self.checked() if not check_outdated else []
+ current = self.current()
+ self.table.setSortingEnabled(False)
+ self.table.setRowCount(len(self.rows))
+ for i, r in enumerate(self.rows):
+ r["state"] = upstream.state(r, self.names, self.versions, self.errors)
+ box = QTableWidgetItem()
+ box.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
+ on = r["state"] == "outdated" if check_outdated else r["name"] in checked
+ box.setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked)
+ box.setData(Qt.ItemDataRole.UserRole, r["name"])
+ self.table.setItem(i, 0, box)
+ up = (self.versions or {}).get(r["name"], "")
+ for col, text in enumerate((r["name"], r["hint_version"] or "", r["info_version"] or "",
+ up, r["state"]), 1):
+ self.table.setItem(i, col, QTableWidgetItem(text))
+ state = self.table.item(i, 5)
+ if r["state"] in STATE_COLOR:
+ state.setForeground(QColor(STATE_COLOR[r["state"]]))
+ if r["state"] == "nvchecker error":
+ state.setToolTip(self.errors.get(r["name"]) or self.failure or "no result")
+ self.table.setSortingEnabled(True)
+ if current:
+ self.select(current)
+ self.refilter()
+
+ def refilter(self):
+ on = {n for n, b in self.chips.items() if b.isChecked()}
+ text = self.query.text().lower()
+ shown = 0
+ for i in range(self.table.rowCount()):
+ name, state = self.table.item(i, 1).text(), self.table.item(i, 5).text()
+ hide = (on and state not in on) or text not in name.lower()
+ self.table.setRowHidden(i, bool(hide))
+ shown += not hide
+ n = len(self.checked())
+ self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else ""))
+
+ def checked(self):
+ return [self.table.item(i, 0).data(Qt.ItemDataRole.UserRole)
+ for i in range(self.table.rowCount())
+ if self.table.item(i, 0).checkState() == Qt.CheckState.Checked]
+
+ def current(self):
+ i = self.table.currentRow()
+ return self.table.item(i, 1).text() if i >= 0 and self.table.item(i, 1) else None
+
+ def select(self, name):
+ for i in range(self.table.rowCount()):
+ if self.table.item(i, 1).text() == name:
+ self.table.setRowHidden(i, False)
+ self.table.selectRow(i)
+ return
+
+ def check(self):
+ self.check_btn.setEnabled(False)
+ self.status.setText("checking upstream…")
+ self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas)
+
+ def _checked(self, versions, errors, failure):
+ self.check_btn.setEnabled(True)
+ self.versions, self.errors, self.failure = versions, errors, failure
+ self._fill(check_outdated=True)
+
+ def update_selected(self):
+ versions = self.versions or {}
+ pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions]
+ if pairs:
+ self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else []))
+
+ def delete(self):
+ name = self.current()
+ if name and QMessageBox.question(self, "Delete hint", f"Delete the hint of {name}? "
+ "(kept as .bak, Revert restores it)") \
+ == QMessageBox.StandardButton.Yes:
+ self.remote.call(self._changed, "hint-delete", name)
+
+ def _changed(self, _data, err):
+ if err:
+ QMessageBox.warning(self, "Hint", err)
+ self.reload()
+
+
+class HintResultDialog(QDialog):
+ """After a hint job: each changed package's diff, Revert per package, Build these."""
+
+ build_requested = pyqtSignal(list) # package names
+ changed = pyqtSignal()
+
+ def __init__(self, remote, result, parent=None):
+ super().__init__(parent)
+ self.remote = remote
+ self.setWindowTitle("Hint changes")
+ self.resize(900, 600)
+ self.list = QListWidget()
+ for pkg in result["ok"]:
+ item = QListWidgetItem(pkg)
+ item.setData(Qt.ItemDataRole.UserRole, pkg)
+ self.list.addItem(item)
+ self.list.currentItemChanged.connect(self._show)
+ self.diff = QPlainTextEdit(readOnly=True)
+ self.diff.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ split = QSplitter()
+ split.addWidget(self.list)
+ split.addWidget(self.diff)
+ split.setSizes([250, 650])
+ failed = QLabel("\n".join(f"{p}: {why}" for p, why in result["failed"]))
+ failed.setStyleSheet(f"color: {STATUS['failed']}")
+ failed.setVisible(bool(result["failed"]))
+ self.revert_btn = QPushButton("Revert")
+ self.revert_btn.clicked.connect(self.revert)
+ build = QPushButton("Build these")
+ build.clicked.connect(self.build)
+ close = QPushButton("Close")
+ close.clicked.connect(self.accept)
+ bar = QHBoxLayout()
+ bar.addWidget(self.revert_btn)
+ bar.addStretch()
+ bar.addWidget(build)
+ bar.addWidget(close)
+ layout = QVBoxLayout(self)
+ layout.addWidget(split, 1)
+ layout.addWidget(failed)
+ layout.addLayout(bar)
+ if self.list.count():
+ self.list.setCurrentRow(0) # the diff of each package is fetched when selected
+
+ def _show(self, item, _prev):
+ self.diff.clear()
+ if item:
+ pkg = item.data(Qt.ItemDataRole.UserRole)
+ self.remote.call(lambda d, err: self._diff(pkg, d, err), "hint-diff", pkg)
+
+ def _diff(self, pkg, data, err):
+ item = self.list.currentItem()
+ if not item or item.data(Qt.ItemDataRole.UserRole) != pkg:
+ return # another package was selected meanwhile
+ if err:
+ self.diff.setPlainText(err)
+ return
+ old = (data["bak"] or "").splitlines(keepends=True)
+ new = (data["current"] or "").splitlines(keepends=True)
+ self.diff.setPlainText("".join(difflib.unified_diff(
+ old, new, f"{pkg}.hint.bak" if data["bak"] is not None else "/dev/null",
+ f"{pkg}.hint" if data["current"] is not None else "/dev/null")) or "no change")
+
+ def revert(self):
+ item = self.list.currentItem()
+ if not item or item.data(Qt.ItemDataRole.UserRole + 1):
+ return # a second revert would delete the restored hint
+ pkg = item.data(Qt.ItemDataRole.UserRole)
+
+ def done(_data, err):
+ if err:
+ QMessageBox.warning(self, "Revert", err)
+ return
+ item.setText(f"{pkg} (reverted)")
+ item.setData(Qt.ItemDataRole.UserRole + 1, True)
+ self.diff.setPlainText("reverted")
+ self.changed.emit()
+ self.remote.call(done, "hint-revert", pkg)
+
+ def build(self):
+ pkgs = [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count())
+ if not self.list.item(i).data(Qt.ItemDataRole.UserRole + 1)]
+ if pkgs:
+ self.build_requested.emit(pkgs)
+ self.accept()
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
index 247b0cf..0331eb9 100644
--- a/sb_builder/mainwindow.py
+++ b/sb_builder/mainwindow.py
@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QSty
from . import VERSION, config
from .dashboard import Dashboard
from .fmt import job_title
+from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab
from .jobs import JobsTab
from .packages import PackagesTab
from .results import ResultsTab
@@ -37,6 +38,7 @@ NOTIFY_PATH = "/org/freedesktop/Notifications"
# ribbon entries: label, freedesktop icon name, Qt fallback icon
RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon),
("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView),
+ ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView),
("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning),
("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay))
# tabs as a distinct, centered row; colors from the palette so the theme applies
@@ -87,6 +89,8 @@ class MainWindow(QMainWindow):
self.jobs_tab = None
self.packages_tab = None
self.results_tab = None
+ self.hints_tab = None
+ self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown
self.seen = None # job id -> status at the last poll; None before the first
self.online = None # None until the first answer
self.overlays = []
@@ -119,7 +123,8 @@ class MainWindow(QMainWindow):
self.ribbon.setIconSize(QSize(32, 32))
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon)
slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard),
- "Packages": self.open_packages, "Results": lambda: self.open_results(),
+ "Packages": self.open_packages, "Hints": lambda: self.open_hints(),
+ "Results": lambda: self.open_results(),
"Jobs": self.open_jobs}
for label, name, fallback in RIBBON:
icon = QIcon.fromTheme(name, self.style().standardIcon(fallback))
@@ -281,9 +286,27 @@ class MainWindow(QMainWindow):
if event and prev.get(j["id"], "running") != j["status"]:
self.notify(event, job_title(j), j["id"])
if j["kind"] in ("slackrepo", "publish"): # build state changed
- for tab in (self.packages_tab, self.results_tab):
- if tab:
- tab.reload()
+ self._reload(self.packages_tab, self.results_tab)
+ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
+ self._reload(self.packages_tab, self.hints_tab)
+ self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"])
+
+ @staticmethod
+ def _reload(*tabs):
+ for tab in tabs:
+ if tab:
+ tab.reload()
+
+ def show_hint_result(self, result):
+ if not result["ok"] and not result["failed"]:
+ return # nothing changed, nothing failed: no news
+ d = HintResultDialog(self.remote, result, self)
+ d.build_requested.connect(
+ lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs))
+ d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab))
+ d.finished.connect(lambda _r: self.hint_dialogs.remove(d))
+ self.hint_dialogs.append(d)
+ d.show()
# ── tabs ────────────────────────────────────────────────────────────────
@@ -304,11 +327,23 @@ class MainWindow(QMainWindow):
if self.packages_tab is None:
self.packages_tab = PackagesTab(self.remote)
self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
+ self.packages_tab.hints_requested.connect(self.open_hints)
self.tabs.addTab(self.packages_tab, "Packages")
self.packages_tab.reload()
self.tabs.setCurrentWidget(self.packages_tab)
return self.packages_tab
+ def open_hints(self, name=None):
+ if self.hints_tab is None:
+ self.hints_tab = HintsTab(self.remote, self.cfg)
+ self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
+ self.tabs.addTab(self.hints_tab, "Hints")
+ self.hints_tab.reload()
+ if name:
+ self.hints_tab.select(name)
+ self.tabs.setCurrentWidget(self.hints_tab)
+ return self.hints_tab
+
def open_results(self, result=None):
if self.results_tab is None:
self.results_tab = ResultsTab(self.remote)
@@ -338,6 +373,8 @@ class MainWindow(QMainWindow):
self.packages_tab = None
if w is self.results_tab:
self.results_tab = None
+ if w is self.hints_tab:
+ self.hints_tab = None
w.deleteLater()
def reload_config(self):
diff --git a/sb_builder/packages.py b/sb_builder/packages.py
index 34ce7f5..146db51 100644
--- a/sb_builder/packages.py
+++ b/sb_builder/packages.py
@@ -20,8 +20,9 @@ import html
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal
from PyQt6.QtGui import QColor
-from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton,
- QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget)
+from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
+ QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter,
+ QTableView, QVBoxLayout, QWidget)
from . import hints as hintlib
from .dashboard import STATUS
@@ -209,6 +210,7 @@ def table(rows):
class PackagesTab(QWidget):
start_requested = pyqtSignal(list) # job-start arguments
+ hints_requested = pyqtSignal(str) # package to show in the Hints tab
def __init__(self, remote, parent=None):
super().__init__(parent)
@@ -245,8 +247,22 @@ class PackagesTab(QWidget):
alignment=Qt.AlignmentFlag.AlignTop)
self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.detail.setContentsMargins(12, 8, 12, 8)
+ self.new_hint_btn = QPushButton("New hint")
+ self.new_hint_btn.clicked.connect(self.new_hint)
+ self.open_hint_btn = QPushButton("Open in Hints")
+ self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"]))
+ detail_bar = QHBoxLayout()
+ detail_bar.addWidget(self.new_hint_btn)
+ detail_bar.addWidget(self.open_hint_btn)
+ detail_bar.addStretch()
+ pane = QWidget()
+ pane_layout = QVBoxLayout(pane)
+ pane_layout.addWidget(self.detail)
+ pane_layout.addLayout(detail_bar)
+ pane_layout.addStretch()
scroll = QScrollArea(widgetResizable=True)
- scroll.setWidget(self.detail)
+ scroll.setWidget(pane)
+ self.show_detail(QModelIndex(), None)
split = QSplitter()
split.addWidget(self.table)
split.addWidget(scroll)
@@ -327,6 +343,37 @@ class PackagesTab(QWidget):
== QMessageBox.StandardButton.Yes:
self.start_requested.emit(["publish"])
+ def current(self):
+ index = self.table.currentIndex()
+ return index.data(Qt.ItemDataRole.UserRole) if index.isValid() else None
+
def show_detail(self, current, _previous):
row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None
self.detail.setText(detail_html(row) if row else "")
+ self.new_hint_btn.setVisible(bool(row) and not row["hint_path"])
+ self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"]))
+
+ def ask_new_hint(self, name):
+ """(version or "", no download) for a new hint, None on cancel."""
+ d = QDialog(self, windowTitle=f"New hint: {name}")
+ version = QLineEdit(placeholderText="the .info version")
+ no_dl = QCheckBox('no download (DOWNLOAD="no")')
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
+ | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(d.accept)
+ buttons.rejected.connect(d.reject)
+ form = QFormLayout(d)
+ form.addRow("version", version)
+ form.addRow(no_dl)
+ form.addRow(buttons)
+ if d.exec() != QDialog.DialogCode.Accepted:
+ return None
+ return version.text().strip(), no_dl.isChecked()
+
+ def new_hint(self):
+ row = self.current()
+ answer = row and self.ask_new_hint(row["name"])
+ if answer:
+ version, no_dl = answer
+ self.start_requested.emit(["hint-new", row["name"], *([version] if version else []),
+ *(["--no-dl"] if no_dl else [])])
diff --git a/tests/test_gui.py b/tests/test_gui.py
index 0da4243..aa9c03b 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -450,13 +450,82 @@ class SmokeTest(unittest.TestCase):
w, _ = self.window()
w.poll_jobs()
self.assertEqual([a.text() for a in w.ribbon.actions()],
- ["Dashboard", "Packages", "Results", "Jobs"])
+ ["Dashboard", "Packages", "Hints", "Results", "Jobs"])
self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"])
w.ribbon.actions()[1].trigger()
self.assertIs(w.tabs.currentWidget(), w.packages_tab)
w.ribbon.actions()[0].trigger()
self.assertIs(w.tabs.currentWidget(), w.dashboard)
+ def test_hints_tab(self):
+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None),
+ "hint-delete": ({"ok": True}, None)})
+ w.poll_jobs()
+ tab = w.open_hints()
+ with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
+ f.write('[nuclei]\nsource = "manual"\n[curl]\nsource = "manual"\n')
+ self.addCleanup(os.unlink, f.name)
+ tab.stanzas = f.name
+ tab.reload()
+ self.assertEqual(sorted(r["name"] for r in tab.rows), ["curl", "foo", "nuclei"]) # foo: hint only
+ states = {tab.table.item(i, 1).text(): tab.table.item(i, 5).text() for i in range(3)}
+ self.assertEqual(states, {"curl": "not checked", "foo": "no stanza", "nuclei": "not checked"})
+ tab._checked({"nuclei": "3.12.0", "curl": "1.0"}, {}, "")
+ self.assertEqual(tab.checked(), ["nuclei"]) # outdated rows come checked
+ tab.chips["current"].setChecked(True)
+ visible = [tab.table.item(i, 1).text() for i in range(3) if not tab.table.isRowHidden(i)]
+ self.assertEqual(visible, ["curl"])
+ tab.no_dl.setChecked(True)
+ tab.update_selected()
+ self.assertIn(("job-start", "hint-update", "nuclei=3.12.0", "--no-dl"), remote.calls)
+ tab._checked({}, {"curl": "HTTPError(404)"}, "")
+ row = next(i for i in range(3) if tab.table.item(i, 1).text() == "curl")
+ self.assertEqual(tab.table.item(row, 5).toolTip(), "HTTPError(404)")
+ self.assertIs(w.open_hints("foo"), tab)
+ self.assertEqual(tab.current(), "foo")
+
+ def test_hint_job_end_opens_diff(self):
+ job = dict(JOB, id="20260101-000000-hint-update", kind="hint-update", args=["nuclei=3.12.0"])
+ w, remote = self.window(jobs=({"jobs": [job]}, None),
+ **{"job-result": ({"ok": ["nuclei"], "failed": [["curl", "HTTP 404"]]}, None),
+ "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'},
+ None),
+ "hint-revert": ({"ok": True}, None),
+ "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
+ w.poll_jobs()
+ remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None)
+ w.poll_jobs()
+ self.assertEqual(len(w.hint_dialogs), 1)
+ d = w.hint_dialogs[0]
+ self.assertIn('-VERSION="3.11.1"\n+VERSION="3.12.0"', d.diff.toPlainText())
+ d.revert()
+ d.revert() # a second revert must not reach the helper: it would delete the hint
+ self.assertEqual([c for c in remote.calls if c[0] == "hint-revert"], [("hint-revert", "nuclei")])
+ self.assertEqual(d.list.item(0).text(), "nuclei (reverted)")
+ d.build() # nothing left to build
+ self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
+ self.assertEqual(w.hint_dialogs, [])
+ w.show_hint_result({"ok": ["nuclei"], "failed": []})
+ w.hint_dialogs[0].build()
+ self.assertIn(("job-start", "slackrepo", "update", "--preview", "nuclei"), remote.calls)
+
+ def test_packages_new_hint(self):
+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-new"}, None)})
+ w.poll_jobs()
+ tab = w.open_packages()
+ tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint
+ self.assertFalse(tab.new_hint_btn.isVisibleTo(tab))
+ tab.open_hint_btn.click()
+ self.assertIs(w.tabs.currentWidget(), w.hints_tab)
+ tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint
+ self.assertTrue(tab.new_hint_btn.isVisibleTo(tab))
+ tab.ask_new_hint = lambda name: None
+ tab.new_hint()
+ self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
+ tab.ask_new_hint = lambda name: ("8.0", True)
+ tab.new_hint()
+ self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls)
+
def test_tab_shortcuts_wrap(self):
w, _ = self.window()
w.poll_jobs()