diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-20 11:16:23 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-20 11:16:23 +0200 |
| commit | bafab248a348e861511c8a984bdafa48fc32215f (patch) | |
| tree | c67d14bb0b32464676f38e43c3f6e1abd4ef9dcd /sb_builder/hints_tab.py | |
| parent | 2989606c3c0bcf08e99468067b0ab73c6810aa1c (diff) | |
| download | sb-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>
Diffstat (limited to 'sb_builder/hints_tab.py')
| -rw-r--r-- | sb_builder/hints_tab.py | 277 |
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() |
