aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/jobs.py
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 /sb_builder/jobs.py
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>
Diffstat (limited to 'sb_builder/jobs.py')
-rw-r--r--sb_builder/jobs.py154
1 files changed, 102 insertions, 52 deletions
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")