aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--config.example.toml3
-rw-r--r--sb_builder/config.py1
-rw-r--r--sb_builder/mainwindow.py8
-rw-r--r--sb_builder/repository.py182
-rw-r--r--tests/test_gui.py42
5 files changed, 230 insertions, 6 deletions
diff --git a/config.example.toml b/config.example.toml
index e192a28..0211621 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -23,3 +23,6 @@ publish = true # initial state of the "publish" box next to Run it
[hints]
nvchecker_keyfile = "~/.config/nvchecker/keys.toml" # GitHub token for nvchecker; optional
+
+[repository]
+path = "/repo" # local mount of slackrepo's PKGREPO, for the Repository tab
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))
diff --git a/tests/test_gui.py b/tests/test_gui.py
index 44418ac..73146c1 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -430,7 +430,7 @@ class SmokeTest(unittest.TestCase):
self.assertEqual(w.dashboard.legend.text(), "n/a")
jobs = w.open_jobs()
self.assertIs(w.open_jobs(), jobs)
- self.assertEqual(w.tabs.count(), 5)
+ self.assertEqual(w.tabs.count(), 6)
self.assertEqual([a.text() for a in jobs.pull_menu.actions()], ["personal (preview)"])
jobs.pull_menu.actions()[0].trigger()
self.assertIn(("job-start", "sbpull", "personal", "--no"), remote.calls)
@@ -627,11 +627,46 @@ class SmokeTest(unittest.TestCase):
w.dashboard.jobs.clicked.emit()
self.assertIs(w.tabs.currentWidget(), w.jobs_tab)
+ def wait(self, cond, secs=5):
+ end = time.time() + secs
+ while not cond() and time.time() < end:
+ app.processEvents()
+ self.assertTrue(cond())
+
+ def test_repository_tab(self):
+ root = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, root)
+ make_repo(root)
+ w, _remote = self.window()
+ w.cfg["repository"]["path"] = root
+ tab = w.repository_tab
+ w.tabs.setCurrentWidget(tab)
+ self.wait(lambda: tab.model.rows)
+ self.assertIn("2 packages", tab.stats.text())
+ self.assertEqual(tab.proxy.rowCount(), 2)
+ tab.chips["unpublished"].setChecked(True)
+ self.assertEqual(tab.proxy.rowCount(), 1)
+ tab.chips["unpublished"].setChecked(False)
+ tab.query.setText("test package")
+ self.assertEqual(tab.proxy.rowCount(), 1)
+ tab.table.setCurrentIndex(tab.proxy.index(0, 0))
+ self.assertIn("Updated for version 1.0.", tab.detail.text())
+ tab.link.linkActivated.emit("#")
+ self.assertIs(w.tabs.currentWidget(), w.packages_tab)
+ self.assertEqual(w.packages_tab.query.text(), "foo")
+
+ def test_repository_missing(self):
+ w, _remote = self.window()
+ w.cfg["repository"]["path"] = "/nonexistent/repo"
+ w.tabs.setCurrentWidget(w.repository_tab)
+ self.wait(lambda: "not a directory" in w.repository_tab.stats.text())
+
def test_ribbons_per_tab(self):
w, remote = self.window()
+ w.cfg["repository"]["path"] = "/nonexistent/repo"
w.poll_jobs()
self.assertEqual([w.tabs.tabText(i) for i in range(w.tabs.count())],
- ["Dashboard", "Packages", "Hints", "Results", "Jobs"])
+ ["Dashboard", "Packages", "Hints", "Results", "Repository", "Jobs"])
self.assertFalse(w.tabs.tabsClosable())
self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show
expect = {
@@ -641,6 +676,7 @@ class SmokeTest(unittest.TestCase):
"Hints": (["Check upstream", "Update selected", "Delete hint", "Phantom deps…"],
["Select visible", "Clear", "No download", "Refresh"]),
"Results": (["Show build log"], ["Refresh"]),
+ "Repository": ([], ["Refresh"]),
"Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []),
}
for i in range(w.tabs.count()):
@@ -851,7 +887,7 @@ class SmokeTest(unittest.TestCase):
nxt.activated.emit()
self.assertEqual(w.tabs.currentIndex(), 0) # wrapped from Jobs to Dashboard
prev.activated.emit()
- self.assertEqual(w.tabs.currentIndex(), 4)
+ self.assertEqual(w.tabs.currentIndex(), 5)
def test_results_tab(self):
w, remote = self.window()