# sb-builder - Hints tab # Copyright (C) 2026 Danilo M. # # 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) for label, on in (("Select visible", True), ("Clear", False)): b = QPushButton(label) b.clicked.connect(lambda _c, on=on: self.check_rows(on)) bar.addWidget(b) 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 check_rows(self, on): """Tick every visible row (on), or untick every row.""" state = Qt.CheckState.Checked if on else Qt.CheckState.Unchecked for i in range(self.table.rowCount()): if not on or not self.table.isRowHidden(i): self.table.item(i, 0).setCheckState(state) self.refilter() 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()