# 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, QKeySequence, QShortcut, QSyntaxHighlighter from PyQt6.QtWidgets import (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 from .ui import SP, action, enable 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 result_ready = pyqtSignal(dict) # {"ok", "failed"} of hint changes made here, for the dialog 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.status = QLabel() chips.addWidget(self.status) 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.table.cellClicked.connect(self._toggle) self.act_check = action(self, "Check upstream", "system-search", SP.SP_FileDialogContentsView, self.check) self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton, self.update_selected) self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete) self.actions = [self.act_check, self.act_update, self.act_delete] self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True) self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source ' "already in its cache") self.view_actions = [ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, lambda: self.check_rows(True), shortcut="Ctrl+A"), action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, lambda: self.check_rows(False), shortcut="Ctrl+Shift+A"), self.no_dl, action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] # in the search field Ctrl+A still selects its text: QLineEdit overrides it self.keys = [QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on), context=Qt.ShortcutContext.WidgetWithChildrenShortcut) for key, on in (("Ctrl+A", True), ("Ctrl+Shift+A", False))] self.loaded = False layout = QVBoxLayout(self) layout.addWidget(self.query) layout.addLayout(chips) layout.addWidget(self.table, 1) self.refilter() def reload(self): self.loaded = True 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) # not user-checkable: _toggle owns every click 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 ticked = set(self.checked()) self.status.setText(f"{shown} shown · {len(ticked)} selected" + (f" · {self.failure}" if self.failure else "")) enable(self.act_update, None if self.pairs() else "tick outdated packages after Check upstream") enable(self.act_delete, None if any(r["hint_path"] for r in self.rows if r["name"] in ticked) else "none of the ticked packages has a hint") def _toggle(self, row, _col): """A click anywhere on a row, its box included, ticks or unticks it.""" box = self.table.item(row, 0) box.setCheckState(Qt.CheckState.Unchecked if box.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked) self.refilter() 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): enable(self.act_check, "checking…") self.status.setText("checking upstream…") self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas) def _checked(self, versions, errors, failure): enable(self.act_check, None) self.versions, self.errors, self.failure = versions, errors, failure self._fill(check_outdated=True) def pairs(self): """pkg=version of the ticked packages Check upstream found a version for.""" versions = self.versions or {} return [f"{n}={versions[n]}" for n in self.checked() if n in versions] def update_selected(self): if self.pairs(): self.start_requested.emit(["hint-update", *self.pairs()] + (["--no-dl"] if self.no_dl.isChecked() else [])) def confirm(self, title, text): return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes def delete(self): """Delete the hints of the ticked packages, then the dialog to revert them.""" ticked = set(self.checked()) names = [r["name"] for r in self.rows if r["name"] in ticked and r["hint_path"]] if not names or not self.confirm( "Delete hint", f"Delete the hint of {len(names)} package(s): {', '.join(names)}?\n" "Each is kept as .bak; the next dialog can revert it."): return result = {"ok": [], "failed": []} def done(name, err): (result["failed"].append([name, err]) if err else result["ok"].append(name)) if len(result["ok"]) + len(result["failed"]) == len(names): self.reload() self.result_ready.emit(result) for name in names: self.remote.call(lambda _d, err, n=name: done(n, err), "hint-delete", name) class DiffColors(QSyntaxHighlighter): """Unified diff colors: added green, removed red, hunk headers blue.""" COLORS = (("+++", None), ("---", None), ("+", STATUS["ok"]), ("-", STATUS["failed"]), ("@@", "#3b8bd0")) def highlightBlock(self, text): color = next((c for prefix, c in self.COLORS if text.startswith(prefix)), None) if color: self.setFormat(0, len(text), QColor(color)) 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.colors = DiffColors(self.diff.document()) 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()