diff options
Diffstat (limited to 'sb_builder')
| -rw-r--r-- | sb_builder/config.py | 1 | ||||
| -rw-r--r-- | sb_builder/mainwindow.py | 8 | ||||
| -rw-r--r-- | sb_builder/repository.py | 182 |
3 files changed, 188 insertions, 3 deletions
diff --git a/sb_builder/config.py b/sb_builder/config.py index 8164f75..85d7694 100644 --- a/sb_builder/config.py +++ b/sb_builder/config.py @@ -27,6 +27,7 @@ DEFAULTS = { "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False}, "builds": {"publish": True}, "hints": {"nvchecker_keyfile": "~/.config/nvchecker/keys.toml"}, + "repository": {"path": "/repo"}, } diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py index 3860079..5ebe869 100644 --- a/sb_builder/mainwindow.py +++ b/sb_builder/mainwindow.py @@ -31,6 +31,7 @@ from .fmt import job_title, outcome from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab from .jobs import JobsTab, NextStep, is_preview from .packages import PackagesTab +from .repository import RepositoryTab from .results import ResultsTab from .remote import DOWN, MISSING @@ -122,10 +123,12 @@ class MainWindow(QMainWindow): self.hints_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args)) self.hints_tab.result_ready.connect(self.show_hint_result) self.results_tab = ResultsTab(self.remote) + self.repository_tab = RepositoryTab(self.cfg) + self.repository_tab.packages_requested.connect(lambda n: self.open_packages().query.setText(n)) # permanent tabs (UI guidelines); each loads on first show for tab, label in ((self.dashboard, "Dashboard"), (self.packages_tab, "Packages"), (self.hints_tab, "Hints"), (self.results_tab, "Results"), - (self.jobs_tab, "Jobs")): + (self.repository_tab, "Repository"), (self.jobs_tab, "Jobs")): self.tabs.addTab(tab, label) self.setCentralWidget(self.tabs) @@ -416,7 +419,8 @@ class MainWindow(QMainWindow): """(key, widget) of every table header and splitter in the tabs.""" # ponytail: keyed by tab class and creation order; a reordered layout # restores the wrong widths once, then saves the right ones - for tab in (self.dashboard, self.packages_tab, self.hints_tab, self.results_tab, self.jobs_tab): + for tab in (self.dashboard, self.packages_tab, self.hints_tab, self.results_tab, + self.repository_tab, self.jobs_tab): for kind in (QHeaderView, QSplitter): for n, w in enumerate(tab.findChildren(kind)): yield f"{type(tab).__name__}/{kind.__name__}{n}", w diff --git a/sb_builder/repository.py b/sb_builder/repository.py index 3344ade..3cead0d 100644 --- a/sb_builder/repository.py +++ b/sb_builder/repository.py @@ -19,11 +19,17 @@ import calendar import glob import os +import html import re +import threading from collections import Counter +from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget + from .fmt import NA, fmt_bytes, fmt_time -from .packages import OVERLAYS +from .packages import OVERLAYS, table +from .ui import SP, action, chip, group_label, search_field PKGFILE = re.compile(r"^(?P<name>.+)-(?P<version>[^-]+)-(?P<arch>[^-]+)-(?P<build>[^-]+)\.t[gx]z$") ITEM = re.compile(r"^([^/\s]+/[^/\s]+)/[^/\s]+\.t[gx]z:\s*(.*)$") # category/pkg/file: text @@ -124,3 +130,177 @@ def summary(data): *(f"{by[o]} {o}" for o in (*OVERLAYS, "upstream")), f"{sum(not r['published'] for r in rows)} unpublished", f"published {fmt_time(data['published']) if data['published'] else NA}"]) + + +COLUMNS = ("name", "version", "build", "category", "size", "date", "published") +SORT = Qt.ItemDataRole.UserRole + 1 + + +class Model(QAbstractTableModel): + def __init__(self): + super().__init__() + self.rows = [] + + def set_rows(self, rows): + self.beginResetModel() + self.rows = rows + 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 data(self, index, role=Qt.ItemDataRole.DisplayRole): + r, col = self.rows[index.row()], index.column() + if role == Qt.ItemDataRole.DisplayRole: + return (r["name"], r["version"], r["build"], r["category"], fmt_bytes(r["size"]), + fmt_time(r["mtime"]), "yes" if r["published"] else "no")[col] + if role == SORT: # sizes and dates as numbers + return (r["name"], r["version"], r["build"], r["category"], r["size"], r["mtime"], + r["published"])[col] + if role == Qt.ItemDataRole.UserRole: + return r + return None + + +class Filter(QSortFilterProxyModel): + def __init__(self): + super().__init__(sortRole=SORT) + self.text, self.overlays, self.unpublished = "", set(), False + + def update(self, text, overlays, unpublished): + self.text, self.overlays, self.unpublished = text.lower(), overlays, unpublished + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, _parent): + r = self.sourceModel().rows[source_row] + if self.overlays and overlay(r) not in self.overlays: + return False + if self.unpublished and r["published"]: + return False + return all(w in f"{r['name']} {r['desc']}".lower() for w in self.text.split()) + + +def detail_html(r): + e = html.escape + out = [f"<h3>{e(r['name'])} {e(r['version'])}</h3>", + f"<p style='white-space:pre-wrap'>{e(r['desc'])}</p>" if r["desc"] else "", + table([("file", r["file"]), ("size", fmt_bytes(r["size"])), + ("uncompressed", fmt_bytes(r["usize"])), ("built", fmt_time(r["mtime"])), + ("published", "yes" if r["published"] else "no, not in PACKAGES.TXT")])] + if r["log"]: + out.append("<h4>ChangeLog</h4>" + table( + [(fmt_time(t) if t else NA, text) for t, text in r["log"]])) + return "".join(out) + + +class RepositoryTab(QWidget): + packages_requested = pyqtSignal(str) # package to show in the Packages tab + scanned = pyqtSignal(object, str) # scan() result or None, error; from the loader thread + + def __init__(self, cfg, parent=None): + super().__init__(parent) + self.cfg = cfg + self.model = Model() + self.proxy = Filter() + self.proxy.setSourceModel(self.model) + self.scanned.connect(self._loaded) + + self.query = search_field("search name and description") + self.query.textChanged.connect(self.refilter) + self.chips = {} + chips = QHBoxLayout() + for label, group in (("overlay", (*OVERLAYS, "upstream")), ("state", ("unpublished",))): + chips.addWidget(group_label(label)) + for name in group: + self.chips[name] = chip(name) + self.chips[name].toggled.connect(self.refilter) + chips.addWidget(self.chips[name]) + chips.addSpacing(12) + chips.addStretch() + + 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((220, 120, 80, 110, 90, 130)): + self.table.setColumnWidth(col, width) + self.table.selectionModel().currentRowChanged.connect(self.show_detail) + 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.link = QLabel('<a href="#">Open in Packages</a>', visible=False) + self.link.setContentsMargins(12, 0, 12, 8) + self.link.linkActivated.connect(lambda _l: self.packages_requested.emit(self.current()["name"])) + pane = QWidget() + pane_layout = QVBoxLayout(pane) + pane_layout.addWidget(self.detail) + pane_layout.addWidget(self.link) + pane_layout.addStretch() + scroll = QScrollArea(widgetResizable=True) + scroll.setWidget(pane) + split = QSplitter() + split.addWidget(self.table) + split.addWidget(scroll) + split.setSizes([700, 400]) + + self.actions = [] # nothing to change here: Publish lives in Packages + self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] + self.loaded = False + + self.stats = QLabel(wordWrap=True) + self.count = QLabel() + search = QHBoxLayout() + search.addWidget(self.query) + search.addWidget(self.count) + layout = QVBoxLayout(self) + layout.addWidget(self.stats) + layout.addLayout(search) + layout.addLayout(chips) + layout.addWidget(split, 1) + + def reload(self): + """Scan in a thread: a hard NFS mount can hang on a dead server.""" + self.loaded = True + self.stats.setText("loading…") + path = self.cfg["repository"]["path"] + + def work(): + try: + self.scanned.emit(scan(path), "") + except OSError as e: + self.scanned.emit(None, str(e)) + threading.Thread(target=work, daemon=True).start() + + def _loaded(self, data, err): + self.model.set_rows(data["rows"] if data else []) + self.stats.setText(err or summary(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 - {"unpublished"}, "unpublished" in on) + self.update_count() + + def update_count(self): + self.count.setText(f"{self.proxy.rowCount():,} shown") + + 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.link.setVisible(bool(row)) |
