aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/hints_tab.py
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder/hints_tab.py')
-rw-r--r--sb_builder/hints_tab.py277
1 files changed, 277 insertions, 0 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()