# sb-builder - Packages 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. """Packages tab: search the inventory, read a package's hints, start slackrepo previews.""" import html from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal from PyQt6.QtGui import QColor, QKeySequence, QShortcut from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget) from . import hints as hintlib from .dashboard import STATUS from .fmt import NA, fmt_time from .ui import SP, action, enable OVERLAYS = ("personal", "pentesting") STATUSES = ("ok", "failed", "never", "outdated") COLUMNS = ("", "name", "category", "version", "built", "status", "maintainer") STATUS_COLOR = {"ok": STATUS["ok"], "failed": STATUS["failed"], "outdated": STATUS["aborted"]} FIELDS = {"cat": "category", "maint": "maintainer", "req": "requires", "status": "status"} def status(row): # ponytail: outdated compares versions only; slackrepo also rebuilds on # SlackBuild or dependency changes, which its preview shows if not row["built_version"] and not row["result"]: return "never" if row["result"] in ("failed", "bad", "aborted"): return "failed" if row["built_version"] != row["version"]: return "outdated" return "ok" def overlay(row): cat = row["path"].split("/", 1)[0] return cat if cat in OVERLAYS else "upstream" def parse_query(text): """[(field or None, term)], lower-cased; unknown prefixes count as plain text.""" terms = [] for word in text.lower().split(): field, sep, term = word.partition(":") if sep and field in FIELDS and term: terms.append((FIELDS[field], term)) else: terms.append((None, word)) return terms def matches(row, terms, overlays=(), statuses=()): """All terms match, and the row is in a toggled chip of each non-empty group.""" if overlays and overlay(row) not in overlays: return False if statuses and row["status"] not in statuses: return False for field, term in terms: if field is None: hay = f"{row['name']} {row['summary'] or ''}" elif field == "requires": hay = " ".join(row["requires"]) else: hay = row[field] or "" if term not in hay.lower(): return False return True def load_rows(inventory): rows = [] for values in inventory["rows"]: row = dict(zip(inventory["columns"], values)) row["category"], row["name"] = row["path"].split("/", 1) row["status"] = status(row) rows.append(row) return rows class Model(QAbstractTableModel): def __init__(self): super().__init__() self.rows = [] self.checked = set() # package names def set_rows(self, rows): self.beginResetModel() self.rows = rows names = {r["name"] for r in rows} self.checked &= names self.endResetModel() def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self.rows) def columnCount(self, parent=QModelIndex()): return len(COLUMNS) def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: return COLUMNS[section] return None def flags(self, index): # not user-checkable: PackagesTab._toggle owns every click, the box's included return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable def data(self, index, role=Qt.ItemDataRole.DisplayRole): row, col = self.rows[index.row()], index.column() if role == Qt.ItemDataRole.CheckStateRole and col == 0: return Qt.CheckState.Checked if row["name"] in self.checked else Qt.CheckState.Unchecked if role == Qt.ItemDataRole.DisplayRole: return (None, row["name"], row["category"], row["version"], row["built_version"] or "", row["status"], row["maintainer"] or "")[col] if role == Qt.ItemDataRole.ForegroundRole and col == 5 and row["status"] in STATUS_COLOR: return QColor(STATUS_COLOR[row["status"]]) if role == Qt.ItemDataRole.UserRole: return row return None def setData(self, index, value, role=Qt.ItemDataRole.EditRole): if role != Qt.ItemDataRole.CheckStateRole or index.column() != 0: return False name = self.rows[index.row()]["name"] if Qt.CheckState(value) == Qt.CheckState.Checked: self.checked.add(name) else: self.checked.discard(name) self.dataChanged.emit(index, index) return True class Filter(QSortFilterProxyModel): def __init__(self): super().__init__() self.terms, self.overlays, self.statuses = [], set(), set() def update(self, text, overlays, statuses): self.terms, self.overlays, self.statuses = parse_query(text), overlays, statuses self.invalidateFilter() def filterAcceptsRow(self, source_row, _parent): row = self.sourceModel().rows[source_row] return matches(row, self.terms, self.overlays, self.statuses) def chip(text): b = QPushButton(text, checkable=True) b.setStyleSheet("QPushButton { border: 1px solid palette(mid); border-radius: 10px;" " padding: 2px 10px; }" "QPushButton:checked { background: palette(highlight);" " color: palette(highlighted-text); }") return b def detail_html(row): """The detail pane for one package: facts, dependencies, then its hints.""" view = hintlib.interpret(row["hints"], row["name"], row["info_version"], row["requires"]) e = html.escape out = [f"

{e(row['name'])}

{e(row['path'])}" f"{' · ' + e(row['summary']) if row['summary'] else ''}

"] if view["skip"]: out.append(f'

⚠ Skipped by slackrepo: ' f"{e(view['skip'])}

") facts = [(".info version", row["info_version"]), ("hint version", row["hint_version"]), ("built version", row["built_version"]), ("built", fmt_time(row["built_time"]) if row["built_time"] else None), ("last result", row["result"]), ("maintainer", row["maintainer"])] out.append(table([(k, v or NA) for k, v in facts])) marks = {"": "", "del": "{} (DELREQUIRES)", "add": "+{} (ADDREQUIRES)", "build": "+{} (build time only)", "pragma": "+{} (pragma)"} deps = [(marks[m] or "{}").format(e(d)) for d, m in view["deps"]] out.append(f"

Dependencies

{'   '.join(deps) or 'none'}

") if view["conflicts"]: out.append(f"

Conflicts: {e(' '.join(view['conflicts']))}

") for title, rows in view["sections"]: out.append(f"

{e(title)}

" + table(rows)) if view["shell"]: out.append(f"

Expanded by the shell at build time, shown as written: " f"{e(', '.join(view['shell']))}

") if view["ignored"]: out.append(f'

Not read by slackrepo: ' f"{e(', '.join(view['ignored']))}

") if row["hint_path"]: out.append(f"

hint: {e(row['hint_path'])}

") return "".join(out) def table(rows): cells = "".join(f"{html.escape(str(k))}" f"{html.escape(str(v))}" for k, v in rows) return f"{cells}
" 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) self.remote = remote self.model = Model() self.proxy = Filter() self.proxy.setSourceModel(self.model) self.query = QLineEdit(placeholderText="search (cat: maint: req: status:)") self.query.textChanged.connect(self.refilter) self.chips = {} chips = QHBoxLayout() for group in (("personal", "pentesting", "upstream"), STATUSES): for name in group: self.chips[name] = chip(name) self.chips[name].toggled.connect(self.refilter) chips.addWidget(self.chips[name]) chips.addSpacing(16) chips.addStretch() self.count = QLabel() chips.addWidget(self.count) self.table = QTableView(sortingEnabled=True) self.table.setModel(self.proxy) self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection) self.table.verticalHeader().hide() self.table.horizontalHeader().setStretchLastSection(True) for col, width in enumerate((28, 220, 110, 120, 120, 80)): self.table.setColumnWidth(col, width) self.table.selectionModel().currentRowChanged.connect(self.show_detail) self.table.clicked.connect(self._toggle) self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True, alignment=Qt.AlignmentFlag.AlignTop) self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.detail.setContentsMargins(12, 8, 12, 8) # navigation, not an action: a link in the pane (UI guidelines) self.open_hint_btn = QLabel('Open in Hints') self.open_hint_btn.setContentsMargins(12, 0, 12, 8) self.open_hint_btn.linkActivated.connect(lambda _l: self.hints_requested.emit(self.current()["name"])) pane = QWidget() pane_layout = QVBoxLayout(pane) pane_layout.addWidget(self.detail) pane_layout.addWidget(self.open_hint_btn) pane_layout.addStretch() scroll = QScrollArea(widgetResizable=True) scroll.setWidget(pane) self.show_detail(QModelIndex(), None) split = QSplitter() split.addWidget(self.table) split.addWidget(scroll) split.setSizes([700, 400]) self.act_build = { a: action(self, a.capitalize(), icon, fb, lambda a=a: self.act(a)) for a, icon, fb in (("build", "run-build", SP.SP_MediaPlay), ("update", "system-software-update", SP.SP_BrowserReload), ("rebuild", "view-refresh", SP.SP_BrowserReload), ("remove", "edit-delete", SP.SP_TrashIcon))} self.act_new_hint = action(self, "New hint", "document-new", SP.SP_FileIcon, self.new_hint) self.actions = [*self.act_build.values(), action(self, "Update all", "system-upgrade", SP.SP_ArrowUp, self.update_all), self.act_new_hint, action(self, "Publish", "document-send", SP.SP_DialogSaveButton, self.publish)] self.view_actions = [ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, self.select_visible, shortcut="Ctrl+A"), action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, self.clear, shortcut="Ctrl+Shift+A"), 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, slot, context=Qt.ShortcutContext.WidgetWithChildrenShortcut) for key, slot in (("Ctrl+A", self.select_visible), ("Ctrl+Shift+A", self.clear))] self.loaded = False layout = QVBoxLayout(self) layout.addWidget(self.query) layout.addLayout(chips) layout.addWidget(split, 1) self.model.dataChanged.connect(lambda *_: self.update_count()) self.update_count() def reload(self): self.loaded = True self.count.setText("loading…") self.remote.call(self._loaded, "inventory") def _loaded(self, data, err): if err: self.count.setText(f"inventory failed: {err}") return self.model.set_rows(load_rows(data)) self.update_count() def refilter(self): on = {n for n, b in self.chips.items() if b.isChecked()} self.proxy.update(self.query.text(), on - set(STATUSES), on & set(STATUSES)) self.update_count() def update_count(self): self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected") for a in self.act_build.values(): enable(a, None if self.model.checked else "tick packages first") enable(self.act_new_hint, None if self.without_hint() else "none of the ticked packages is without a hint") def without_hint(self): """Ticked packages that have no hint, sorted.""" return sorted(r["name"] for r in self.model.rows if r["name"] in self.model.checked and not r["hint_path"]) def _toggle(self, index): """A click anywhere on a row ticks or unticks it.""" box = index.siblingAtColumn(0) on = box.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked self.proxy.setData(box, Qt.CheckState.Unchecked if on else Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole) def select_visible(self): for i in range(self.proxy.rowCount()): self.model.checked.add(self.proxy.index(i, 0).data(Qt.ItemDataRole.UserRole)["name"]) self.model.layoutChanged.emit() self.update_count() def clear(self): self.model.checked.clear() self.model.layoutChanged.emit() self.update_count() def targets(self): """Ticked packages, sorted; the highlighted row alone is not a target.""" return sorted(self.model.checked) def act(self, action): names = self.targets() if names: self.start_requested.emit(["slackrepo", action, "--preview", *names]) def update_all(self): self.start_requested.emit(["slackrepo", "update", "--preview"]) def publish(self): if QMessageBox.question(self, "Publish", "Push the package repository to the mirror now?") \ == 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.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) def new_hint(self): names = self.without_hint() if names: self.start_requested.emit(["hint-new", *names])