aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 12:13:00 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 12:13:00 +0200
commitd4cae7a87fd30341b5a936aa848e1fdc019a379e (patch)
tree4a8a21966595468046f1b39b345a49a03c76f25e
parentc8306d6c87a40bb800241cccd90831e3893550b5 (diff)
downloadsb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.tar.gz
sb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.zip
One frame for every tab: ribbons of actions, next steps as dialogs
Tabs are permanent and load on first show. The ribbon shows the current tab's actions in workflow order, a small ribbon under it the view controls; inapplicable actions are disabled with the reason as tooltip. Starting a job stays on the tab; the Jobs tab reads (running), then shows the end status. When a preview this session started ends, its log opens with Run it (publish box) or Apply shadowing; Not now loses nothing, Run it in Jobs reopens it. New hint moves to the Packages ribbon and acts on the ticked packages without a hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--sb_builder/dashboard.py9
-rw-r--r--sb_builder/hints_tab.py81
-rw-r--r--sb_builder/jobs.py154
-rw-r--r--sb_builder/mainwindow.py174
-rw-r--r--sb_builder/packages.py108
-rw-r--r--sb_builder/results.py22
-rw-r--r--sb_builder/ui.py46
-rw-r--r--tests/test_gui.py219
8 files changed, 503 insertions, 310 deletions
diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py
index 5dee453..3096fb8 100644
--- a/sb_builder/dashboard.py
+++ b/sb_builder/dashboard.py
@@ -22,10 +22,11 @@ import time
from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QPainter, QPalette
-from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton,
- QToolTip, QVBoxLayout, QWidget)
+from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QToolTip, QVBoxLayout,
+ QWidget)
from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title
+from .ui import SP, action
# reserved status colors (dataviz reference palette); always shown with a text label
STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b",
@@ -288,11 +289,11 @@ class Dashboard(QWidget):
self.jobs.body.addStretch()
self.stale = muted(QLabel())
- self.refresh_btn = QPushButton("Refresh")
+ self.refresh = action(self, "Refresh", "view-refresh", SP.SP_BrowserReload)
+ self.actions, self.view_actions = [self.refresh], []
top = QHBoxLayout()
top.addWidget(self.stale)
top.addStretch()
- top.addWidget(self.refresh_btn)
grid = QGridLayout()
grid.setSpacing(12)
diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py
index b78ec69..feba94d 100644
--- a/sb_builder/hints_tab.py
+++ b/sb_builder/hints_tab.py
@@ -21,13 +21,14 @@ import os
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter
-from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+from PyQt6.QtWidgets import (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
+from .ui import SP, action, enable
STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error")
STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"],
@@ -59,9 +60,8 @@ class HintsTab(QWidget):
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.status = QLabel()
+ chips.addWidget(self.status)
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
@@ -74,37 +74,36 @@ class HintsTab(QWidget):
self.table.setColumnWidth(col, width)
self.table.cellClicked.connect(self._toggle)
- 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)
- self.keys = []
- for label, on, key in (("Select visible", True, "Ctrl+A"), ("Clear", False, "Ctrl+Shift+A")):
- b = QPushButton(label, toolTip=key)
- b.clicked.connect(lambda _c, on=on: self.check_rows(on))
- bar.addWidget(b)
- # in the search field Ctrl+A still selects its text: QLineEdit overrides it
- self.keys.append(QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on),
- context=Qt.ShortcutContext.WidgetWithChildrenShortcut))
- 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)
+ self.act_check = action(self, "Check upstream", "system-search",
+ SP.SP_FileDialogContentsView, self.check)
+ self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton,
+ self.update_selected)
+ self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete)
+ self.actions = [self.act_check, self.act_update, self.act_delete]
+ self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True)
+ self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source '
+ "already in its cache")
+ self.view_actions = [
+ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton,
+ lambda: self.check_rows(True), shortcut="Ctrl+A"),
+ action(self, "Clear", "edit-clear", SP.SP_DialogResetButton,
+ lambda: self.check_rows(False), shortcut="Ctrl+Shift+A"),
+ self.no_dl,
+ 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, lambda on=on: self.check_rows(on),
+ context=Qt.ShortcutContext.WidgetWithChildrenShortcut)
+ for key, on in (("Ctrl+A", True), ("Ctrl+Shift+A", False))]
+ self.loaded = False
layout = QVBoxLayout(self)
layout.addWidget(self.query)
layout.addLayout(chips)
- layout.addWidget(self.table)
- layout.addLayout(bar)
+ layout.addWidget(self.table, 1)
+ self.refilter()
def reload(self):
+ self.loaded = True
self.status.setText("loading…")
self.remote.call(self._loaded, "inventory")
@@ -152,8 +151,12 @@ class HintsTab(QWidget):
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 ""))
+ ticked = set(self.checked())
+ self.status.setText(f"{shown} shown · {len(ticked)} selected"
+ + (f" · {self.failure}" if self.failure else ""))
+ enable(self.act_update, None if self.pairs() else "tick outdated packages after Check upstream")
+ enable(self.act_delete, None if any(r["hint_path"] for r in self.rows if r["name"] in ticked)
+ else "none of the ticked packages has a hint")
def _toggle(self, row, _col):
"""A click anywhere on a row, its box included, ticks or unticks it."""
@@ -187,20 +190,24 @@ class HintsTab(QWidget):
return
def check(self):
- self.check_btn.setEnabled(False)
+ enable(self.act_check, "checking…")
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)
+ enable(self.act_check, None)
self.versions, self.errors, self.failure = versions, errors, failure
self._fill(check_outdated=True)
- def update_selected(self):
+ def pairs(self):
+ """pkg=version of the ticked packages Check upstream found a version for."""
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 []))
+ return [f"{n}={versions[n]}" for n in self.checked() if n in versions]
+
+ def update_selected(self):
+ if self.pairs():
+ self.start_requested.emit(["hint-update", *self.pairs()]
+ + (["--no-dl"] if self.no_dl.isChecked() else []))
def confirm(self, title, text):
return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes
diff --git a/sb_builder/jobs.py b/sb_builder/jobs.py
index b413597..111d804 100644
--- a/sb_builder/jobs.py
+++ b/sb_builder/jobs.py
@@ -14,18 +14,19 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-"""Jobs tab: job list, live log through job-follow, start and kill."""
+"""Jobs tab: job list, live log through job-follow, start and kill; the next-step dialog."""
import codecs
import re
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFontDatabase, QTextCursor
-from PyQt6.QtWidgets import (QCheckBox, QHBoxLayout, QInputDialog, QLineEdit, QListWidget,
- QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton,
- QSplitter, QToolButton, QVBoxLayout, QWidget)
+from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QInputDialog, QLineEdit,
+ QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
+ QPushButton, QSplitter, QVBoxLayout, QWidget)
-from .fmt import describe
+from .fmt import describe, job_title
+from .ui import SP, action, enable
ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M)
@@ -33,10 +34,88 @@ ACTIVE = ("starting", "running")
BAD = ("failed", "killed", "lost")
+def is_preview(j):
+ """A finished preview whose next step is a real run: slackrepo --preview, sbpull/sbshadow --no."""
+ return j["status"] == "ok" and (
+ (j["kind"] == "slackrepo" and "--preview" in j["args"])
+ or (j["kind"] in ("sbpull", "sbshadow") and "--no" in j["args"]))
+
+
+class NextStep(QDialog):
+ """After a preview: its whole log, then Run it (or Apply shadowing) / Not now.
+
+ run(job-start args) asks for the real run; the caller starts it. For a
+ shadowing preview, loaded(has_plan) tells whether there is anything to apply.
+ """
+
+ run = pyqtSignal(list)
+ loaded = pyqtSignal(bool)
+
+ def __init__(self, remote, job, publish, parent=None):
+ super().__init__(parent)
+ self.job = job
+ self.shadow = job["kind"] != "slackrepo"
+ self.setWindowTitle("Preview: " + job_title(dict(job, args=[
+ a for a in job["args"] if a not in ("--preview", "--no")])))
+ self.resize(900, 600)
+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
+ self.log = QPlainTextEdit(readOnly=True)
+ self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ self.publish = QCheckBox("publish", checked=publish, visible=not self.shadow)
+ self.publish.setToolTip("Push to the mirror when the build ends; unticked, use Publish later")
+ self.run_btn = QPushButton("Apply shadowing" if self.shadow else "Run it", default=True)
+ self.run_btn.clicked.connect(self._run)
+ later = QPushButton("Not now")
+ later.clicked.connect(self.reject)
+ bar = QHBoxLayout()
+ bar.addWidget(self.publish)
+ bar.addStretch()
+ bar.addWidget(later)
+ bar.addWidget(self.run_btn)
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.log)
+ layout.addLayout(bar)
+ self.proc = p = remote.follow(job["id"], 0) # the job has ended: the stream closes by itself
+ p.readyReadStandardOutput.connect(self._chunk)
+ p.finished.connect(self._ended)
+
+ def _chunk(self):
+ data = bytes(self.proc.readAllStandardOutput())
+ self.log.appendPlainText(self.decoder.decode(ANSI.sub(b"", data)).rstrip("\n"))
+
+ def _ended(self, *_):
+ self._chunk()
+ self.proc.deleteLater()
+ if not self.has_plan():
+ self.run_btn.setEnabled(False)
+ self.run_btn.setToolTip("no shadow: or unshadow: lines, nothing to apply")
+ self.loaded.emit(self.has_plan())
+
+ def has_plan(self):
+ return not self.shadow or bool(SHADOW_PLAN.search(self.log.toPlainText()))
+
+ def confirm(self, question):
+ return QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes
+
+ def _run(self):
+ if self.shadow:
+ if not self.confirm("Apply the shadowing plan to the repo (sbshadow --yes)?"):
+ return
+ self.run.emit(["sbshadow", "--yes"])
+ else:
+ args = [a for a in self.job["args"] if a != "--preview"]
+ if not self.publish.isChecked():
+ args.insert(1, "--no-publish")
+ self.run.emit(["slackrepo", *args])
+ self.accept()
+
+
class JobsTab(QWidget):
refresh_requested = pyqtSignal()
+ started = pyqtSignal(str) # id of a job this GUI started
+ preview_requested = pyqtSignal(dict) # a finished preview to take to its next step
- def __init__(self, remote, publish=True, parent=None):
+ def __init__(self, remote, parent=None):
super().__init__(parent)
self.remote = remote
self.pending = ()
@@ -46,25 +125,17 @@ class JobsTab(QWidget):
self.proc = None
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
- self.start_menu = QMenu(self)
- start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup)
- start.setMenu(self.start_menu)
- self.apply_btn = QPushButton("Apply shadowing")
- self.apply_btn.clicked.connect(lambda: self.confirm_start(
- "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes"))
- self.apply_btn.hide()
- self.publish_box = QCheckBox("publish", checked=publish)
- self.publish_box.setToolTip("Push to the mirror when the build ends; "
- "unticked, use Publish later")
- self.run_btn = QPushButton("Run it")
- self.run_btn.clicked.connect(self.run_preview)
- self.kill_btn = QPushButton("Kill")
- self.kill_btn.clicked.connect(self.kill)
- bar = QHBoxLayout()
- for w in (start, self.apply_btn, self.run_btn, self.publish_box):
- bar.addWidget(w)
- bar.addStretch()
- bar.addWidget(self.kill_btn)
+ self.pull_menu = QMenu(self)
+ self.act_pull = action(self, "Pull overlay", "folder-download", SP.SP_ArrowDown)
+ self.act_pull.setMenu(self.pull_menu)
+ self.act_run = action(self, "Run it", "media-playback-start", SP.SP_MediaPlay,
+ lambda: self.preview_requested.emit(self.jobs[self.current]))
+ self.act_kill = action(self, "Kill", "process-stop", SP.SP_BrowserStop, self.kill)
+ self.actions = [self.act_pull,
+ action(self, "Check shadowing", "edit-find", SP.SP_FileDialogContentsView,
+ lambda: self.start("sbshadow", "--no")),
+ self.act_run, self.act_kill]
+ self.view_actions = []
self.list = QListWidget()
self.list.currentItemChanged.connect(self._on_select)
@@ -76,19 +147,14 @@ class JobsTab(QWidget):
split.addWidget(self.log)
split.setSizes([300, 700])
layout = QVBoxLayout(self)
- layout.addLayout(bar)
layout.addWidget(split)
self.set_overlays([])
def set_overlays(self, overlays):
- m = self.start_menu
- m.clear()
+ self.pull_menu.clear()
for o in overlays:
- m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
- m.addSeparator()
- m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no"))
- m.addAction("sbshadow (apply)…", lambda: self.confirm_start(
- "Run sbshadow --yes on the repo?", "sbshadow", "--yes"))
+ self.pull_menu.addAction(f"{o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
+ enable(self.act_pull, None if overlays else "no overlays known yet")
def set_jobs(self, jobs):
self.jobs = {j["id"]: j for j in jobs}
@@ -140,23 +206,12 @@ class JobsTab(QWidget):
self.pending = args
self.remote.call(self._started, "job-start", *args)
- def confirm_start(self, question, *args):
- if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes:
- self.start(*args)
-
def kill(self):
if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \
== QMessageBox.StandardButton.Yes:
self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err),
"job-kill", self.current)
- def run_preview(self):
- """Start the previewed slackrepo run for real."""
- args = [a for a in self.jobs[self.current]["args"] if a != "--preview"]
- if not self.publish_box.isChecked():
- args.insert(1, "--no-publish")
- self.start_signed("slackrepo", *args)
-
def ask_passphrase(self, retry):
text, ok = QInputDialog.getText(
self, "Signing key", ("Wrong passphrase, try again." if retry else
@@ -206,6 +261,7 @@ class JobsTab(QWidget):
self.select(data["busy"])
return
self.select(data["id"])
+ self.started.emit(data["id"])
self.refresh_requested.emit()
def _on_select(self, item, _prev):
@@ -243,11 +299,5 @@ class JobsTab(QWidget):
def _update_buttons(self):
j = self.jobs.get(self.current)
- self.kill_btn.setEnabled(bool(j) and j["status"] == "running")
- self.apply_btn.setVisible(
- bool(j) and j["status"] == "ok" and "--no" in j["args"]
- and bool(SHADOW_PLAN.search(self.log.toPlainText())))
- preview = bool(j) and j["kind"] == "slackrepo" and j["status"] == "ok" \
- and "--preview" in j["args"]
- self.run_btn.setVisible(preview)
- self.publish_box.setVisible(preview)
+ enable(self.act_kill, None if j and j["status"] == "running" else "select a running job")
+ enable(self.act_run, None if j and is_preview(j) else "select a finished preview")
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
index eeac61a..c6ea02e 100644
--- a/sb_builder/mainwindow.py
+++ b/sb_builder/mainwindow.py
@@ -21,26 +21,24 @@ import time
from PyQt6.QtCore import QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
from PyQt6.QtGui import QIcon, QKeySequence, QShortcut
-from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabBar,
- QTabWidget, QToolBar)
+from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabWidget,
+ QToolBar, QToolButton)
from . import VERSION, config
from .dashboard import Dashboard
from .fmt import job_title
from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab
-from .jobs import JobsTab
+from .jobs import JobsTab, NextStep, is_preview
from .packages import PackagesTab
from .results import ResultsTab
from .remote import DOWN, MISSING
NOTIFY = "org.freedesktop.Notifications"
NOTIFY_PATH = "/org/freedesktop/Notifications"
-# ribbon entries: label, freedesktop icon name, Qt fallback icon
-RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon),
- ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView),
- ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView),
- ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning),
- ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay))
+# Jobs tab icon: running, then the end status until the tab is shown
+BADGE = {"running": ("system-run", QStyle.StandardPixmap.SP_MediaPlay),
+ "ok": ("emblem-ok", QStyle.StandardPixmap.SP_DialogApplyButton),
+ "bad": ("dialog-error", QStyle.StandardPixmap.SP_MessageBoxCritical)}
# tabs as a distinct, centered row; colors from the palette so the theme applies
TAB_STYLE = """
QTabWidget::tab-bar { alignment: center; }
@@ -86,11 +84,10 @@ class MainWindow(QMainWindow):
self.cfg, self.remote = cfg, remote
self.notifier = notifier or Notifier(self)
self.notifier.clicked.connect(self.open_job)
- self.jobs_tab = None
- self.packages_tab = None
- self.results_tab = None
- self.hints_tab = None
self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown
+ self.next_steps = {} # job id -> its open NextStep dialog
+ self.mine = set() # ids of the jobs this session started: only they get next steps
+ self.badge = None # Jobs tab icon state: None, "running", "ok", "bad"
self.seen = None # job id -> status at the last poll; None before the first
self.online = None # None until the first answer
self.overlays = []
@@ -99,17 +96,29 @@ class MainWindow(QMainWindow):
self.running_id = None
self.setWindowTitle("sb-builder")
- self.tabs = QTabWidget(tabsClosable=True)
+ self.tabs = QTabWidget()
self.tabs.setStyleSheet(TAB_STYLE)
- self.tabs.tabCloseRequested.connect(self._close_tab)
- self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
self.dashboard = Dashboard()
- self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
+ self.dashboard.refresh.triggered.connect(self.poll_stats)
self.dashboard.tiles["Packages"].link(self.open_packages)
self.dashboard.jobs.link(self.open_jobs)
self.dashboard.result_clicked.connect(self.open_results)
- self.tabs.addTab(self.dashboard, "Dashboard")
- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
+ self.jobs_tab = JobsTab(self.remote)
+ self.jobs_tab.refresh_requested.connect(self.poll_jobs)
+ self.jobs_tab.started.connect(self.mine.add)
+ self.jobs_tab.preview_requested.connect(self.show_next_step)
+ self.packages_tab = PackagesTab(self.remote)
+ self.packages_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args))
+ self.packages_tab.hints_requested.connect(self.open_hints)
+ self.hints_tab = HintsTab(self.remote, self.cfg)
+ 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)
+ # 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.tabs.addTab(tab, label)
self.setCentralWidget(self.tabs)
file_menu = self.menuBar().addMenu("&File")
@@ -118,17 +127,18 @@ class MainWindow(QMainWindow):
# window-wide: QTabWidget's own Ctrl+PgUp/PgDn needs focus on the tabs
self.tab_next = QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: self._cycle_tab(1))
self.tab_prev = QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: self._cycle_tab(-1))
- self.ribbon = QToolBar("Screens", movable=False, floatable=False)
+ # main ribbon: the current tab's actions; small ribbon under it: its view controls
+ self.ribbon = QToolBar("Actions", movable=False, floatable=False)
self.ribbon.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
self.ribbon.setIconSize(QSize(32, 32))
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon)
- slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard),
- "Packages": self.open_packages, "Hints": lambda: self.open_hints(),
- "Results": lambda: self.open_results(),
- "Jobs": self.open_jobs}
- for label, name, fallback in RIBBON:
- icon = QIcon.fromTheme(name, self.style().standardIcon(fallback))
- self.ribbon.addAction(icon, label, slots[label])
+ self.addToolBarBreak()
+ self.view_bar = QToolBar("View", movable=False, floatable=False)
+ self.view_bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.view_bar.setIconSize(QSize(16, 16))
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.view_bar)
+ self.tabs.currentChanged.connect(self._tab_changed)
+ self._tab_changed()
self.conn = QLabel("● connecting")
self.key = QLabel()
@@ -179,9 +189,12 @@ class MainWindow(QMainWindow):
jobs = data["jobs"]
self._notify_ends(jobs)
self.dashboard.show_jobs(jobs)
- if self.jobs_tab:
- self.jobs_tab.set_jobs(jobs)
+ self.jobs_tab.set_jobs(jobs)
run = next((j for j in jobs if j["status"] == "running"), None)
+ if run:
+ self._badge("running")
+ elif self.badge == "running":
+ self._badge(None) # ended unseen (first poll, reconnect): nothing to report
self.running_id = run and run["id"]
self.job_btn.setVisible(run is not None)
if run:
@@ -196,8 +209,7 @@ class MainWindow(QMainWindow):
if err:
return self._failed(err)
self.overlays = data.get("overlays", [])
- if self.jobs_tab:
- self.jobs_tab.set_overlays(self.overlays)
+ self.jobs_tab.set_overlays(self.overlays)
if data["version"] != VERSION:
self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
@@ -285,24 +297,57 @@ class MainWindow(QMainWindow):
event = END_EVENT.get(j["status"])
if event and prev.get(j["id"], "running") != j["status"]:
self.notify(event, job_title(j), j["id"])
+ if self.tabs.currentWidget() is not self.jobs_tab:
+ self._badge("ok" if j["status"] == "ok" else "bad")
if j["kind"] in ("slackrepo", "publish"): # build state changed
self._reload(self.packages_tab, self.results_tab)
if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
self._reload(self.packages_tab, self.hints_tab)
+ if j["id"] not in self.mine:
+ continue # started elsewhere or before this session: news, not a next step
+ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"])
+ elif is_preview(j):
+ self.show_next_step(j, auto=True)
@staticmethod
def _reload(*tabs):
for tab in tabs:
- if tab:
+ if tab.loaded: # a tab not shown yet loads fresh when it is
tab.reload()
+ def show_next_step(self, job, auto=False):
+ """The preview's next step as a dialog; auto: a shadowing preview without a plan shows none."""
+ if job["id"] in self.next_steps:
+ self.next_steps[job["id"]].raise_()
+ return
+ d = NextStep(self.remote, job, self.cfg["builds"]["publish"], self)
+ d.run.connect(lambda args: (self.jobs_tab.start_signed if args[0] == "slackrepo"
+ else self.jobs_tab.start)(*args))
+ d.finished.connect(lambda _r: self.next_steps.pop(job["id"], None))
+ self.next_steps[job["id"]] = d
+ if d.shadow and auto:
+ d.loaded.connect(lambda plan: d.show() if plan else d.reject())
+ else:
+ d.show()
+
+ def _badge(self, state):
+ self.badge = state
+ i = self.tabs.indexOf(self.jobs_tab)
+ # in the text, not a color: TAB_STYLE's tab color would override setTabTextColor
+ self.tabs.setTabText(i, "Jobs (running)" if state == "running" else "Jobs")
+ if state is None:
+ self.tabs.setTabIcon(i, QIcon())
+ return
+ name, fallback = BADGE[state]
+ self.tabs.setTabIcon(i, QIcon.fromTheme(name, self.style().standardIcon(fallback)))
+
def show_hint_result(self, result):
if not result["ok"] and not result["failed"]:
return # nothing changed, nothing failed: no news
d = HintResultDialog(self.remote, result, self)
d.build_requested.connect(
- lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs))
+ lambda pkgs: self.jobs_tab.start("slackrepo", "update", "--preview", *pkgs))
d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab))
d.finished.connect(lambda _r: self.hint_dialogs.remove(d))
self.hint_dialogs.append(d)
@@ -310,49 +355,42 @@ class MainWindow(QMainWindow):
# ── tabs ────────────────────────────────────────────────────────────────
- def open_jobs(self):
- if self.jobs_tab is None:
- self.jobs_tab = JobsTab(self.remote, self.cfg["builds"]["publish"])
- self.jobs_tab.refresh_requested.connect(self.poll_jobs)
- self.jobs_tab.set_overlays(self.overlays)
- self.tabs.addTab(self.jobs_tab, "Jobs")
- self.poll_jobs()
- self.tabs.setCurrentWidget(self.jobs_tab)
- return self.jobs_tab
+ def _tab_changed(self, _i=None):
+ tab = self.tabs.currentWidget()
+ for bar, acts in ((self.ribbon, tab.actions), (self.view_bar, tab.view_actions)):
+ bar.clear()
+ bar.addActions(acts)
+ for a in acts:
+ if a.menu(): # a menu action opens its menu on a plain click
+ bar.widgetForAction(a).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.view_bar.setVisible(bool(tab.view_actions))
+ if not getattr(tab, "loaded", True):
+ tab.reload()
+ if tab is self.jobs_tab and self.badge != "running":
+ self._badge(None)
+ self.poll_stats()
def _cycle_tab(self, step):
self.tabs.setCurrentIndex((self.tabs.currentIndex() + step) % self.tabs.count())
+ def open_jobs(self):
+ self.tabs.setCurrentWidget(self.jobs_tab)
+ return self.jobs_tab
+
def open_packages(self):
- if self.packages_tab is None:
- self.packages_tab = PackagesTab(self.remote)
- self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
- self.packages_tab.hints_requested.connect(self.open_hints)
- self.tabs.addTab(self.packages_tab, "Packages")
- self.packages_tab.reload()
self.tabs.setCurrentWidget(self.packages_tab)
return self.packages_tab
def open_hints(self, name=None):
- if self.hints_tab is None:
- self.hints_tab = HintsTab(self.remote, self.cfg)
- self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
- self.hints_tab.result_ready.connect(self.show_hint_result)
- self.tabs.addTab(self.hints_tab, "Hints")
- self.hints_tab.reload()
+ self.tabs.setCurrentWidget(self.hints_tab)
if name:
self.hints_tab.select(name)
- self.tabs.setCurrentWidget(self.hints_tab)
return self.hints_tab
def open_results(self, result=None):
- if self.results_tab is None:
- self.results_tab = ResultsTab(self.remote)
- self.tabs.addTab(self.results_tab, "Results")
- self.results_tab.reload()
+ self.tabs.setCurrentWidget(self.results_tab)
if result:
self.results_tab.filter_to(result)
- self.tabs.setCurrentWidget(self.results_tab)
return self.results_tab
def open_job(self, jid):
@@ -362,22 +400,6 @@ class MainWindow(QMainWindow):
if jid:
self.open_jobs().select(jid)
- def _close_tab(self, i):
- w = self.tabs.widget(i)
- if w is self.dashboard:
- return
- self.tabs.removeTab(i)
- if w is self.jobs_tab:
- self.jobs_tab = None
- w.stop()
- if w is self.packages_tab:
- self.packages_tab = None
- if w is self.results_tab:
- self.results_tab = None
- if w is self.hints_tab:
- self.hints_tab = None
- w.deleteLater()
-
def reload_config(self):
cfg, err = config.load()
if err:
diff --git a/sb_builder/packages.py b/sb_builder/packages.py
index 64032a2..30215e0 100644
--- a/sb_builder/packages.py
+++ b/sb_builder/packages.py
@@ -20,13 +20,13 @@ import html
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QKeySequence, QShortcut
-from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
- QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter,
- QTableView, QVBoxLayout, QWidget)
+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")
@@ -230,9 +230,8 @@ class PackagesTab(QWidget):
chips.addWidget(self.chips[name])
chips.addSpacing(16)
chips.addStretch()
- self.refresh_btn = QPushButton("Refresh")
- self.refresh_btn.clicked.connect(self.reload)
- chips.addWidget(self.refresh_btn)
+ self.count = QLabel()
+ chips.addWidget(self.count)
self.table = QTableView(sortingEnabled=True)
self.table.setModel(self.proxy)
@@ -248,18 +247,14 @@ class PackagesTab(QWidget):
alignment=Qt.AlignmentFlag.AlignTop)
self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.detail.setContentsMargins(12, 8, 12, 8)
- self.new_hint_btn = QPushButton("New hint")
- self.new_hint_btn.clicked.connect(self.new_hint)
- self.open_hint_btn = QPushButton("Open in Hints")
- self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"]))
- detail_bar = QHBoxLayout()
- detail_bar.addWidget(self.new_hint_btn)
- detail_bar.addWidget(self.open_hint_btn)
- detail_bar.addStretch()
+ # navigation, not an action: a link in the pane (UI guidelines)
+ self.open_hint_btn = QLabel('<a href="#">Open in Hints</a>')
+ 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.addLayout(detail_bar)
+ pane_layout.addWidget(self.open_hint_btn)
pane_layout.addStretch()
scroll = QScrollArea(widgetResizable=True)
scroll.setWidget(pane)
@@ -269,37 +264,38 @@ class PackagesTab(QWidget):
split.addWidget(scroll)
split.setSizes([700, 400])
- self.count = QLabel()
- bar = QHBoxLayout()
- bar.addWidget(self.count)
- self.keys = []
- for label, slot, key in (("Select visible", self.select_visible, "Ctrl+A"),
- ("Clear", self.clear, "Ctrl+Shift+A")):
- b = QPushButton(label, toolTip=key)
- b.clicked.connect(slot)
- bar.addWidget(b)
- # in the search field Ctrl+A still selects its text: QLineEdit overrides it
- self.keys.append(QShortcut(QKeySequence(key), self, slot,
- context=Qt.ShortcutContext.WidgetWithChildrenShortcut))
- bar.addStretch()
- for action in ("build", "update", "rebuild", "remove"):
- b = QPushButton(action.capitalize())
- b.clicked.connect(lambda _c, a=action: self.act(a))
- bar.addWidget(b)
- for label, slot in (("Update all", self.update_all), ("Publish", self.publish)):
- b = QPushButton(label)
- b.clicked.connect(slot)
- bar.addWidget(b)
+ 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)
- layout.addLayout(bar)
+ 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")
@@ -317,6 +313,15 @@ class PackagesTab(QWidget):
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."""
@@ -360,30 +365,9 @@ class PackagesTab(QWidget):
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.new_hint_btn.setVisible(bool(row) and not row["hint_path"])
self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"]))
- def ask_new_hint(self, name):
- """(version or "", no download) for a new hint, None on cancel."""
- d = QDialog(self, windowTitle=f"New hint: {name}")
- version = QLineEdit(placeholderText="the .info version")
- no_dl = QCheckBox('no download (DOWNLOAD="no")')
- buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel)
- buttons.accepted.connect(d.accept)
- buttons.rejected.connect(d.reject)
- form = QFormLayout(d)
- form.addRow("version", version)
- form.addRow(no_dl)
- form.addRow(buttons)
- if d.exec() != QDialog.DialogCode.Accepted:
- return None
- return version.text().strip(), no_dl.isChecked()
-
def new_hint(self):
- row = self.current()
- answer = row and self.ask_new_hint(row["name"])
- if answer:
- version, no_dl = answer
- self.start_requested.emit(["hint-new", row["name"], *([version] if version else []),
- *(["--no-dl"] if no_dl else [])])
+ names = self.without_hint()
+ if names:
+ self.start_requested.emit(["hint-new", *names])
diff --git a/sb_builder/results.py b/sb_builder/results.py
index 1fc6b9a..21fdcc3 100644
--- a/sb_builder/results.py
+++ b/sb_builder/results.py
@@ -21,12 +21,13 @@ import re
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor
-from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
- QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
+from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QSplitter,
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
from .dashboard import STATUS
from .fmt import NA, fmt_time
from .packages import chip
+from .ui import SP, action, enable
KINDS = ("ok", "failed", "bad", "aborted", "removed")
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
@@ -75,9 +76,10 @@ class ResultsTab(QWidget):
bar.addStretch()
self.count = QLabel()
bar.addWidget(self.count)
- refresh = QPushButton("Refresh")
- refresh.clicked.connect(self.reload)
- bar.addWidget(refresh)
+ self.act_log = action(self, "Show build log", "text-x-generic", SP.SP_FileIcon, self.show_log)
+ self.actions = [self.act_log]
+ self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)]
+ self.loaded = False
self.table = QTableWidget(0, 4, sortingEnabled=True)
self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"])
@@ -94,13 +96,9 @@ class ResultsTab(QWidget):
self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True,
alignment=Qt.AlignmentFlag.AlignTop)
self.detail.setContentsMargins(12, 8, 12, 8)
- self.log_btn = QPushButton("Show build log")
- self.log_btn.clicked.connect(self.show_log)
- self.log_btn.setEnabled(False)
side = QWidget()
col = QVBoxLayout(side)
col.addWidget(self.detail)
- col.addWidget(self.log_btn)
col.addStretch()
split = QSplitter()
split.addWidget(self.table)
@@ -109,9 +107,11 @@ class ResultsTab(QWidget):
layout = QVBoxLayout(self)
layout.addLayout(bar)
- layout.addWidget(split)
+ layout.addWidget(split, 1)
+ self.show_detail()
def reload(self):
+ self.loaded = True
self.count.setText("loading…")
self.remote.call(self._loaded, "results")
@@ -166,7 +166,7 @@ class ResultsTab(QWidget):
def show_detail(self):
row = self.current()
- self.log_btn.setEnabled(bool(row and row[4]))
+ enable(self.act_log, None if row and row[4] else "select a result with a log")
if not row:
self.detail.setText("")
return
diff --git a/sb_builder/ui.py b/sb_builder/ui.py
new file mode 100644
index 0000000..087d1a7
--- /dev/null
+++ b/sb_builder/ui.py
@@ -0,0 +1,46 @@
+# sb-builder - ribbon actions
+# 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.
+
+"""Ribbon actions shared by the tabs (UI guidelines in AGENTS.md).
+
+Each tab owns two lists of QActions: `actions` for the main ribbon (what it
+does, in workflow order) and `view_actions` for the small one (selection and
+view controls). The main window shows the current tab's lists.
+"""
+
+from PyQt6.QtGui import QAction, QIcon
+from PyQt6.QtWidgets import QStyle
+
+
+def action(parent, text, icon, fallback, slot=None, checkable=False, shortcut=None):
+ """A ribbon action: freedesktop icon with a QStyle fallback."""
+ a = QAction(QIcon.fromTheme(icon, parent.style().standardIcon(fallback)), text, parent,
+ checkable=checkable)
+ if slot:
+ a.triggered.connect(lambda _checked=False: slot())
+ a.setData(shortcut) # shown in the tooltip; the tab's QShortcut does the work
+ enable(a, None)
+ return a
+
+
+def enable(a, reason):
+ """Enable a, or disable it with reason as its tooltip: disabled, never hidden."""
+ a.setEnabled(not reason)
+ tip = a.text() + (f" ({a.data()})" if a.data() else "")
+ a.setToolTip(f"{tip}: {reason}" if reason else tip)
+
+
+SP = QStyle.StandardPixmap
diff --git a/tests/test_gui.py b/tests/test_gui.py
index 1f3f7b0..ee568c1 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -29,7 +29,7 @@ import unittest # noqa: E402
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, ROOT)
-from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QPointF, QProcess, Qt, pyqtSignal # noqa: E402
+from PyQt6.QtCore import QObject, QPointF, QProcess, pyqtSignal # noqa: E402
from PyQt6.QtGui import QColor # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
@@ -309,7 +309,7 @@ class SmokeTest(unittest.TestCase):
return w, remote
def test_tabs_and_dashboard(self):
- w, _ = self.window()
+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-sbpull"}, None)})
w.poll_jobs()
self.assertEqual(w.conn.text(), "● connected")
self.assertEqual(w.tabs.tabText(0), "Dashboard")
@@ -328,8 +328,10 @@ 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(), 2)
- self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)")
+ self.assertEqual(w.tabs.count(), 5)
+ 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)
def test_end_notification_once(self):
w, remote = self.window(jobs=({"jobs": [JOB]}, None))
@@ -363,30 +365,6 @@ class SmokeTest(unittest.TestCase):
[("ok", 3), ("failed", 1), ("removed", 1)])
self.assertEqual(w.dashboard.tiles["Built"].detail.text(), "of 10 · 6 never built")
- def test_close_jobs_tab_while_following(self):
- # regression: the follow process ended after the tab was deleted, its
- # finished slot touched dead widgets and PyQt aborted the app
- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
- procs = QObject()
- self.addCleanup(procs.deleteLater)
-
- def follow(jid, offset):
- p = QProcess(procs)
- p.start("sleep", ["5"])
- return p
-
- remote.follow = follow
- w.poll_jobs()
- jobs = w.open_jobs()
- jobs.select(JOB["id"])
- self.assertIsNotNone(jobs.proc)
- w._close_tab(w.tabs.indexOf(jobs))
- QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
- end = time.monotonic() + 1
- while time.monotonic() < end: # let the killed process report in
- app.processEvents()
- self.assertIsNone(w.jobs_tab)
-
def test_packages_tab(self):
w, remote = self.window(**{"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
w.poll_jobs()
@@ -422,34 +400,107 @@ class SmokeTest(unittest.TestCase):
["slackrepo", "build", "--preview", "curl", "nuclei"],
["slackrepo", "update", "--preview"]])
- def test_run_preview_with_signing(self):
- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
- **{"gpg-status": ({"unlocked": False, "key": "K"}, None),
- "gpg-unlock": lambda args, stdin: (
- (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)),
- "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
+ def end_preview(self, job, **answers):
+ """A window where job, started by this session, goes from running to its status."""
+ w, remote = self.window(jobs=({"jobs": [dict(job, status="running")]}, None), **answers)
+ w.mine.add(job["id"])
w.poll_jobs()
- jobs = w.open_jobs()
- jobs.select(PREVIEW["id"])
- self.assertTrue(jobs.run_btn.isVisibleTo(jobs))
+ remote.answers["jobs"] = ({"jobs": [job]}, None)
+ w.poll_jobs()
+ return w, remote
+
+ def test_preview_next_step_with_signing(self):
+ w, remote = self.end_preview(
+ PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None),
+ "gpg-unlock": lambda args, stdin: (
+ (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)),
+ "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
+ self.assertIs(w.tabs.currentWidget(), w.dashboard) # nobody was sent to Jobs
+ d = w.next_steps[PREVIEW["id"]]
+ self.assertEqual(d.windowTitle(), "Preview: slackrepo update nuclei")
+ self.assertTrue(d.publish.isChecked())
answers = iter(["wrong", "right"])
- jobs.ask_passphrase = lambda retry: next(answers)
- jobs.publish_box.setChecked(False)
- jobs.run_preview()
+ w.jobs_tab.ask_passphrase = lambda retry: next(answers)
+ d.publish.setChecked(False)
+ d.run_btn.click()
self.assertIn(("gpg-unlock", b"wrong\n"), remote.calls)
self.assertIn(("gpg-unlock", b"right\n"), remote.calls)
- self.assertEqual(remote.calls[-2], ("job-start", "slackrepo", "update", "--no-publish", "nuclei"))
+ self.assertIn(("job-start", "slackrepo", "update", "--no-publish", "nuclei"), remote.calls)
+ self.assertEqual(w.next_steps, {})
- def test_signing_cancel_starts_nothing(self):
- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
- **{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
- w.poll_jobs()
+ def test_preview_not_now_then_run_it(self):
+ w, remote = self.end_preview(PREVIEW, **{"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
+ w.next_steps[PREVIEW["id"]].reject()
+ self.assertEqual(w.next_steps, {})
jobs = w.open_jobs()
jobs.select(PREVIEW["id"])
- jobs.ask_passphrase = lambda retry: None
- jobs.run_preview()
+ self.assertTrue(jobs.act_run.isEnabled())
+ jobs.act_run.trigger() # Not now loses nothing
+ w.next_steps[PREVIEW["id"]].run_btn.click()
+ self.assertIn(("job-start", "slackrepo", "update", "nuclei"), remote.calls)
+
+ def test_signing_cancel_starts_nothing(self):
+ w, remote = self.end_preview(PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
+ w.jobs_tab.ask_passphrase = lambda retry: None
+ w.next_steps[PREVIEW["id"]].run_btn.click()
self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
+ def test_preview_from_elsewhere_has_no_dialog(self):
+ w, remote = self.window(jobs=({"jobs": [dict(PREVIEW, status="running")]}, None))
+ w.poll_jobs()
+ remote.answers["jobs"] = ({"jobs": [PREVIEW]}, None)
+ w.poll_jobs()
+ self.assertEqual(w.next_steps, {})
+ self.assertEqual(w.notifier.sent[0][0], "sb-builder: finished")
+
+ def test_shadow_preview_dialog_only_with_a_plan(self):
+ shadow = dict(PREVIEW, id="20260101-000000-sbshadow", kind="sbshadow", args=["--no"])
+ for log, shown in (("shadow: misc/foo\n", True),
+ ("sbshadow: nothing to shadow or unshadow.\n", False)):
+ with self.subTest(log=log):
+ procs = QObject()
+ self.addCleanup(procs.deleteLater)
+
+ def follow(jid, offset, log=log):
+ p = QProcess(procs)
+ p.start("printf", ["%s", log])
+ return p
+ w, remote = self.window(jobs=({"jobs": [dict(shadow, status="running")]}, None),
+ **{"job-start": ({"id": "20260101-000001-sbshadow"}, None)})
+ remote.follow = follow
+ w.mine.add(shadow["id"])
+ w.poll_jobs()
+ remote.answers["jobs"] = ({"jobs": [shadow]}, None)
+ w.poll_jobs()
+ d = w.next_steps.get(shadow["id"])
+ end = time.monotonic() + 3
+ while d and not d.isVisible() and shadow["id"] in w.next_steps and time.monotonic() < end:
+ app.processEvents()
+ self.assertEqual(shadow["id"] in w.next_steps and d.isVisible(), shown)
+ if shown:
+ self.assertEqual(d.run_btn.text(), "Apply shadowing")
+ self.assertFalse(d.publish.isVisibleTo(d))
+ d.confirm = lambda q: True
+ d.run_btn.click()
+ self.assertIn(("job-start", "sbshadow", "--yes"), remote.calls)
+
+ def test_jobs_tab_badge(self):
+ w, remote = self.window(jobs=({"jobs": [JOB]}, None))
+ w.poll_jobs()
+ jobs_i = w.tabs.indexOf(w.jobs_tab)
+ self.assertEqual(w.badge, "running")
+ self.assertFalse(w.tabs.tabIcon(jobs_i).isNull())
+ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs (running)")
+ remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
+ w.poll_jobs()
+ self.assertEqual(w.badge, "bad") # stays until Jobs is shown
+ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs")
+ w.poll_jobs()
+ self.assertEqual(w.badge, "bad")
+ w.open_jobs()
+ self.assertIsNone(w.badge)
+ self.assertTrue(w.tabs.tabIcon(jobs_i).isNull())
+
def test_key_indicator_and_tile_links(self):
w, _ = self.window(**{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
w.poll_jobs()
@@ -459,16 +510,46 @@ class SmokeTest(unittest.TestCase):
w.dashboard.jobs.clicked.emit()
self.assertIs(w.tabs.currentWidget(), w.jobs_tab)
- def test_ribbon_opens_tabs(self):
- w, _ = self.window()
+ def test_ribbons_per_tab(self):
+ w, remote = self.window()
w.poll_jobs()
- self.assertEqual([a.text() for a in w.ribbon.actions()],
+ self.assertEqual([w.tabs.tabText(i) for i in range(w.tabs.count())],
["Dashboard", "Packages", "Hints", "Results", "Jobs"])
+ self.assertFalse(w.tabs.tabsClosable())
+ self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show
+ expect = {
+ "Dashboard": (["Refresh"], []),
+ "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"],
+ ["Select visible", "Clear", "Refresh"]),
+ "Hints": (["Check upstream", "Update selected", "Delete hint"],
+ ["Select visible", "Clear", "No download", "Refresh"]),
+ "Results": (["Show build log"], ["Refresh"]),
+ "Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []),
+ }
+ for i in range(w.tabs.count()):
+ w.tabs.setCurrentIndex(i)
+ main, view = expect[w.tabs.tabText(i)]
+ self.assertEqual([a.text() for a in w.ribbon.actions()], main)
+ self.assertEqual([a.text() for a in w.view_bar.actions()], view)
+ self.assertEqual(w.view_bar.isVisibleTo(w), bool(view))
+ self.assertEqual(remote.calls.count(("inventory",)), 2) # Packages and Hints, once each
+ w.open_packages()
+ self.assertEqual(remote.calls.count(("inventory",)), 2)
self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"])
- w.ribbon.actions()[1].trigger()
- self.assertIs(w.tabs.currentWidget(), w.packages_tab)
- w.ribbon.actions()[0].trigger()
- self.assertIs(w.tabs.currentWidget(), w.dashboard)
+
+ def test_disabled_actions_say_why(self):
+ w, _ = self.window()
+ w.poll_jobs()
+ p, h, j = w.open_packages(), w.open_hints(), w.open_jobs()
+ for a, why in ((p.act_build["build"], "tick packages first"),
+ (p.act_new_hint, "none of the ticked packages is without a hint"),
+ (h.act_update, "tick outdated packages after Check upstream"),
+ (h.act_delete, "none of the ticked packages has a hint"),
+ (j.act_run, "select a finished preview"), (j.act_kill, "select a running job")):
+ with self.subTest(action=a.text()):
+ self.assertFalse(a.isEnabled())
+ self.assertTrue(a.toolTip().endswith(why), a.toolTip())
+ self.assertEqual(h.view_actions[0].toolTip(), "Select visible (Ctrl+A)")
def test_hints_tab(self):
w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None),
@@ -534,6 +615,7 @@ class SmokeTest(unittest.TestCase):
None),
"hint-revert": ({"ok": True}, None),
"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
+ w.mine.add(job["id"])
w.poll_jobs()
remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None)
w.poll_jobs()
@@ -561,17 +643,17 @@ class SmokeTest(unittest.TestCase):
w.poll_jobs()
tab = w.open_packages()
tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint
- self.assertFalse(tab.new_hint_btn.isVisibleTo(tab))
- tab.open_hint_btn.click()
+ self.assertTrue(tab.open_hint_btn.isVisibleTo(tab))
+ tab.open_hint_btn.linkActivated.emit("#")
self.assertIs(w.tabs.currentWidget(), w.hints_tab)
- tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint
- self.assertTrue(tab.new_hint_btn.isVisibleTo(tab))
- tab.ask_new_hint = lambda name: None
- tab.new_hint()
- self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
- tab.ask_new_hint = lambda name: ("8.0", True)
- tab.new_hint()
- self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls)
+ tab.model.checked.update({"foo"})
+ tab.update_count()
+ self.assertFalse(tab.act_new_hint.isEnabled()) # foo has one
+ tab.model.checked.update({"curl", "nuclei"})
+ tab.update_count()
+ tab.act_new_hint.trigger()
+ self.assertIn(("job-start", "hint-new", "curl", "nuclei"), remote.calls)
+ self.assertIs(w.tabs.currentWidget(), w.hints_tab) # starting a job stays put
def test_tab_shortcuts_wrap(self):
w, _ = self.window()
@@ -583,7 +665,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(), 2)
+ self.assertEqual(w.tabs.currentIndex(), 4)
def test_results_tab(self):
w, remote = self.window()
@@ -597,10 +679,11 @@ class SmokeTest(unittest.TestCase):
tab.chips["failed"].setChecked(False)
self.assertEqual(tab.visible_paths(), ["games/pcsx2", "development/whisper.cpp", "misc/nuclei"])
tab.table.selectRow(1) # whisper.cpp: no log dir
- self.assertFalse(tab.log_btn.isEnabled())
+ self.assertFalse(tab.act_log.isEnabled())
+ self.assertTrue(tab.act_log.toolTip().endswith("select a result with a log"))
tab.table.selectRow(0)
- self.assertTrue(tab.log_btn.isEnabled())
- tab.show_log()
+ self.assertTrue(tab.act_log.isEnabled())
+ tab.act_log.trigger()
self.assertIn(("build-log", "games/pcsx2"), remote.calls)
viewer = tab.viewers[-1]
self.assertIn("ERROR: make failed", viewer.text.toPlainText()) # ANSI stripped