aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-20-sb-builder-ui.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-10-20-sb-builder-ui.md')
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-ui.md1651
1 files changed, 0 insertions, 1651 deletions
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-ui.md b/docs/superpowers/plans/2026-10-20-sb-builder-ui.md
deleted file mode 100644
index 4e80b90..0000000
--- a/docs/superpowers/plans/2026-10-20-sb-builder-ui.md
+++ /dev/null
@@ -1,1651 +0,0 @@
-# sb-builder: one UI frame, implementation plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Every tab in one frame (permanent tabs, a main ribbon of actions, a small ribbon of view controls), actions on ticked rows, and each job's next step as a dialog where the user is.
-
-**Architecture:** `sb_builder/ui.py` builds ribbon `QAction`s (`action`, `enable`); every tab exposes `actions` and `view_actions` and the main window shows the current tab's lists in two toolbars. `JobsTab` gains `NextStep` (preview log, Run it / Apply shadowing) and the `started`/`preview_requested` signals; the main window keeps the ids it started (`mine`) and opens next steps only for them. The helper's `hint-new` takes several packages.
-
-**Tech Stack:** Python 3.11+ stdlib, PyQt6.
-
-Spec: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`.
-
-Every code step was run before this plan was written: the diffs apply to
-`master` at `f4ee0ce` and the suite ends at 115 tests, OK. Screens were
-checked offscreen (each tab's ribbons, filter rows not stretched, the Jobs
-tab reading "Jobs (running)").
-
----
-
-### Task 1: helper, `hint-new` with several packages
-
-**Files:** `remote/sbb-remote`, `sb_builder/__init__.py`, `tests/test_remote.py`
-
-- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u1_test.diff`, then `git apply /tmp/u1_test.diff`.
-
-```diff
-diff --git c/tests/test_remote.py i/tests/test_remote.py
-index 14622b8..54c5614 100644
---- c/tests/test_remote.py
-+++ i/tests/test_remote.py
-@@ -331,7 +331,8 @@ class HintJobsTest(Base):
- me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"]
- self.assertEqual(sbb.KINDS["hint-update"](self.cfg, ["x=2.0", "--no-dl"]),
- (me + ["hint-update", "x=2.0", "--no-dl"], {}))
-- self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "2.0"]), (me + ["hint-new", "x", "2.0"], {}))
-+ self.make_pkg("misc/y", self.info)
-+ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "y"]), (me + ["hint-new", "x", "y"], {}))
- self.assertFalse(sbb.signs("hint-update", ["x=2.0"]))
-
- def test_kinds_reject(self):
-@@ -351,14 +352,14 @@ class HintJobsTest(Base):
- ("hint-update", ["neovim=1"], "bundled deps"),
- ("hint-update", ["shadowed=1"], "not managed"),
- ("hint-new", [], "hint-new <pkg>"),
-- ("hint-new", ["x", "1", "2"], "hint-new <pkg>"),
-- ("hint-new", ["neovim", "1"], "bundled deps")):
-+ ("hint-new", ["x", "2.0"], "no such package: 2.0"), # no version any more
-+ ("hint-new", ["x", "x"], "twice")):
- with self.subTest(kind=kind, args=args), self.assertRaisesRegex(sbb.Error, why):
- sbb.KINDS[kind](self.cfg, args)
- sbb.KINDS["hint-new"](self.cfg, ["neovim"]) # no version: no bundled deps to touch
- self.make_pkg("misc/y", self.info, hint="")
-- with self.assertRaisesRegex(sbb.Error, "exists"):
-- sbb.KINDS["hint-new"](self.cfg, ["y"])
-+ with self.assertRaisesRegex(sbb.Error, "y: hint exists"):
-+ sbb.KINDS["hint-new"](self.cfg, ["x", "y"]) # one with a hint refuses the whole job
-
- def test_update_backs_up_and_reports(self):
- self.make_pkg("misc/x", self.info, hint='DELREQUIRES="rust-opt"\n')
-@@ -388,8 +389,9 @@ class HintJobsTest(Base):
-
- def test_new(self):
- self.make_pkg("misc/x", self.info)
-- rc, result, _ = self.run_hint("hint-new", "x")
-- self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []}))
-+ self.make_pkg("misc/y", self.info)
-+ rc, result, _ = self.run_hint("hint-new", "x", "y")
-+ self.assertEqual((rc, result), (0, {"ok": ["x", "y"], "failed": []}))
- self.assertIn('#REQUIRES="rust-opt a"', self.read(self.hint))
- self.assertIn('MD5SUM="old"', self.read(self.hint)) # no version given: no download
-
-```
-
-- [ ] **Step 2:** Run `python3 -m unittest tests.test_remote`. Expected: FAILED, `hint-new <pkg>` usage and `y: hint exists` assertions.
-- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u1_code.diff`, then `git apply /tmp/u1_code.diff`.
-
-```diff
-diff --git c/remote/sbb-remote i/remote/sbb-remote
-index 2674ed6..5183b77 100755
---- c/remote/sbb-remote
-+++ i/remote/sbb-remote
-@@ -40,7 +40,7 @@ import time
- import tomllib
- import urllib.request
-
--VERSION = "0.5.0"
-+VERSION = "0.6.0"
- DEFAULTS = {
- "state_dir": "/var/lib/sb-builder",
- "repo_dir": "/var/lib/sbopkg/SBo-danix",
-@@ -168,9 +168,9 @@ def hint_args(cfg, kind, args):
- raise Error(f"{kind}: unknown option {o}")
- rest = [a for a in args if a != "--no-dl"]
- if kind == "hint-new":
-- if not 1 <= len(rest) <= 2:
-- raise Error("usage: hint-new <pkg> [<version>] [--no-dl]")
-- pairs = [(rest[0], rest[1] if len(rest) == 2 else None)]
-+ if not rest:
-+ raise Error("usage: hint-new <pkg>... [--no-dl]")
-+ pairs = [(pkg, None) for pkg in rest] # the .info pin; Update sets a version later
- else:
- if not rest:
- raise Error("hint-update needs at least one pkg=version")
-diff --git c/sb_builder/__init__.py i/sb_builder/__init__.py
-index 21a93ab..f9bea08 100644
---- c/sb_builder/__init__.py
-+++ i/sb_builder/__init__.py
-@@ -16,4 +16,4 @@
-
- """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-
--VERSION = "0.5.0" # must match remote/sbb-remote VERSION
-+VERSION = "0.6.0" # must match remote/sbb-remote VERSION
-```
-
-- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK`. (`VersionTest` passes: both files say 0.6.0.)
-- [ ] **Step 5: Commit.**
-
-```bash
-git add remote/sbb-remote sb_builder/__init__.py tests/test_remote.py
-git commit -m "sbb-remote 0.6.0: hint-new takes several packages, no version
-
-Each gets the .info pin; Update selected sets a version later. One
-package that already has a hint refuses the whole job before it starts.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
-```
-
-### Task 2: the frame, ribbons, next-step dialogs
-
-**Files:**
-- Create: `sb_builder/ui.py`
-- Modify: `sb_builder/dashboard.py`, `sb_builder/packages.py`, `sb_builder/hints_tab.py`, `sb_builder/results.py`, `sb_builder/jobs.py`, `sb_builder/mainwindow.py`
-- Test: `tests/test_gui.py`
-
-One task: the tabs' ribbon lists and the main window that shows them only
-work together. Key points: tabs are created at startup, not closable,
-`loaded` flags drive the first `reload()`; `_tab_changed` refills both
-toolbars (a menu action gets `InstantPopup`); `_badge` sets the Jobs tab
-icon and its "(running)" text (the tab style sheet would override a text
-color); `show_next_step` keeps one `NextStep` per job, and for an automatic
-shadowing preview shows it only when `loaded(has_plan)` says there is a plan.
-
-- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u2_test.diff`, then `git apply /tmp/u2_test.diff`.
-
-```diff
-diff --git c/tests/test_gui.py i/tests/test_gui.py
-index 1f3f7b0..ee568c1 100644
---- c/tests/test_gui.py
-+++ i/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
-```
-
-- [ ] **Step 2:** Run `python3 -m unittest tests.test_gui`. Expected: FAILED, e.g. `AttributeError: 'MainWindow' object has no attribute 'mine'`.
-- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u2_code.diff`, then `git apply /tmp/u2_code.diff`.
-
-```diff
-diff --git c/sb_builder/dashboard.py i/sb_builder/dashboard.py
-index 5dee453..3096fb8 100644
---- c/sb_builder/dashboard.py
-+++ i/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 c/sb_builder/hints_tab.py i/sb_builder/hints_tab.py
-index b78ec69..feba94d 100644
---- c/sb_builder/hints_tab.py
-+++ i/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 c/sb_builder/jobs.py i/sb_builder/jobs.py
-index b413597..111d804 100644
---- c/sb_builder/jobs.py
-+++ i/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 c/sb_builder/mainwindow.py i/sb_builder/mainwindow.py
-index eeac61a..c6ea02e 100644
---- c/sb_builder/mainwindow.py
-+++ i/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 c/sb_builder/packages.py i/sb_builder/packages.py
-index 64032a2..30215e0 100644
---- c/sb_builder/packages.py
-+++ i/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 c/sb_builder/results.py i/sb_builder/results.py
-index 1fc6b9a..21fdcc3 100644
---- c/sb_builder/results.py
-+++ i/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 c/sb_builder/ui.py i/sb_builder/ui.py
-new file mode 100644
-index 0000000..087d1a7
---- /dev/null
-+++ i/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
-```
-
-- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK` (115 tests).
-- [ ] **Step 5: Look at it** offscreen: each tab's grab shows the main ribbon, the small ribbon (not on Dashboard and Jobs), the tab bar, the filter rows directly above the table.
-- [ ] **Step 6: Commit.**
-
-```bash
-git add sb_builder tests/test_gui.py
-git commit -m "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>"
-```
-
-### Task 3: docs
-
-**Files:** `README.md`, `AGENTS.md` (new "UI guidelines" section), the UI and phase 4 specs.
-
-- [ ] **Step 1:** Save the diff below as `/tmp/u3.diff`, then `git apply /tmp/u3.diff`.
-
-```diff
-diff --git c/AGENTS.md i/AGENTS.md
-index 9a8918c..f55d952 100644
---- c/AGENTS.md
-+++ i/AGENTS.md
-@@ -44,6 +44,27 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
- that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than
- working around it here.
-
-+## UI guidelines
-+
-+Every tab follows these (design: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`).
-+
-+1. Tabs are permanent and load their data lazily on first show (`loaded`,
-+ `reload()`); the ribbon never opens or closes tabs.
-+2. Main ribbon: the tab's actions (`tab.actions`) in workflow order. Small
-+ ribbon: view controls (`tab.view_actions`). Build them with
-+ `sb_builder/ui.py` `action()`. Nothing actionable lives inside a view
-+ except navigation links.
-+3. Change actions act on ticked rows, view actions on the highlighted row. A
-+ row click toggles its tick; Ctrl+A / Ctrl+Shift+A select visible / clear.
-+4. An inapplicable action is disabled with `ui.enable(action, reason)`, the
-+ reason in its tooltip; never hidden.
-+5. When a job this session started ends, its next step opens as a non-modal
-+ dialog where the user is (`MainWindow.mine`, `NextStep`,
-+ `HintResultDialog`); starting a job never switches tab; the Jobs tab shows
-+ activity; a dismissed step can be taken again from the ribbon.
-+6. A modal confirm only for destructive or outward steps (delete hint,
-+ publish, apply shadowing).
-+
- ## Conventions
-
- - GUI config: `$XDG_CONFIG_HOME/sb-builder/config.toml`. Helper config:
-diff --git c/README.md i/README.md
-index 366b740..ccf5edd 100644
---- c/README.md
-+++ i/README.md
-@@ -33,45 +33,54 @@ Every key is optional. `File > Reload config` rereads the GUI config.
-
- sb-builder
-
-+The window has five tabs, always open: Dashboard, Packages, Hints, Results,
-+Jobs (Ctrl+PgUp/PgDn cycle them). The ribbon at the top holds the current
-+tab's actions, in the order a task uses them; the smaller bar under it holds
-+its view controls (select, clear, refresh). An action that does not apply is
-+greyed out, its tooltip says why. Actions act on the **ticked** rows: a click
-+anywhere on a row ticks or unticks it, Ctrl+A ticks every visible row,
-+Ctrl+Shift+A unticks all.
-+
-+Starting a job keeps you where you are; the Jobs tab shows "(running)" and
-+then the end status. When a job you started ends, its next step comes to you
-+as a dialog: after a preview, its log with **Run it** (and a **publish** box);
-+after a hint change, the diff with **Revert** and **Build these**. **Not
-+now** loses nothing: **Run it** in the Jobs tab, on a finished preview,
-+reopens it.
-+
- - **Dashboard**: VM load, uptime and free disk, package counts, slackrepo
- build results, last overlay pull, recent jobs. Refreshes while visible.
--- **Open > Jobs**: start a job from the Start menu (`sbpull <overlay>`
-- preview, `sbshadow` preview or apply), follow its log live, kill it. After a
-- preview that lists `shadow:` or `unshadow:` lines, "Apply shadowing" runs
-- `sbshadow --yes`.
--
--- **Open > Packages** (or click the Packages tile): every package of the
-- repository, with built version and status (`ok`, `failed`, `never`,
-- `outdated`). Search by name or summary, or with `cat:`, `maint:`, `req:`,
-- `status:`; toggle the overlay and status chips. Ctrl+A ticks every visible
-- package, Ctrl+Shift+A unticks all (here and in Hints). The detail pane shows the
-- package's versions, its dependencies after hints (dropped ones struck
-- through), and what its hint file does, section by section.
--- Tick packages and press Build, Update, Rebuild or Remove (they act on the
-- ticked packages only); Update all updates everything. Each first runs a slackrepo
-- `--preview` job. When the preview is fine, **Run it** in the Jobs tab starts
-- the real run; untick **publish** to keep the packages local and press
-- **Publish** later to push them.
--- **Results** (ribbon, or click a result in the Dashboard's slackrepo bar or
-- legend): slackrepo's last build attempt per package, newest first, filterable
-- by result. **Show build log** (or double-click) opens the end of the log of
-- the package's last attempt (slackrepo keeps one per command: `build.log`,
-- `update.log`, `rebuild.log`, `remove.log`), where it reports the failure,
-- with its `resource.log` (load, memory, tmp over the build).
--- **Hints** (ribbon): every package with a hint or an nvchecker stanza, with
-- hint, `.info` and upstream version. **Check upstream** runs nvchecker on the
-- desktop over `nvchecker.toml` in this repo (stanza name = package name, edit
-- it by hand to track a package; `hints.nvchecker_keyfile` gives it a GitHub
-- token) and ticks the outdated packages. **Update selected** rewrites their
-- hints on the VM (version, URLs, md5 of the downloaded source; **no download**
-- writes `DOWNLOAD="no"` instead; a hint sbshadow wrote, `DELREQUIRES` only,
-- gets the `.info` version lines added). **Delete hint** removes the ticked
-- packages' hints, each kept as a `.bak`. The Packages detail pane has **New hint** for a package
-- without one.
--- After a hint job or a delete a dialog shows the diff of each changed hint, with
-- **Revert** per package and **Build these** (a slackrepo update preview).
-- neovim and openvino are refused for now: their bundled deps still need
-- mkhint on the VM.
-+ The tiles open Packages, Results (one result kind) and Jobs.
-+- **Packages**: every package of the repository, with built version and
-+ status (`ok`, `failed`, `never`, `outdated`). Search by name or summary, or
-+ with `cat:`, `maint:`, `req:`, `status:`; toggle the overlay and status
-+ chips. The detail pane shows the package's versions, its dependencies after
-+ hints (dropped ones struck through), what its hint file does, and a link
-+ to it in Hints. **Build**, **Update**, **Rebuild**, **Remove** and **Update
-+ all** run a slackrepo `--preview` first; **New hint** creates hints, pinned
-+ at the `.info` version, for the ticked packages without one; **Publish**
-+ pushes the package repository (untick **publish** at Run it to hold a run
-+ back and publish later).
-+- **Hints**: every package with a hint or an nvchecker stanza, with hint,
-+ `.info` and upstream version. **Check upstream** runs nvchecker on the
-+ desktop over `nvchecker.toml` in this repo (stanza name = package name,
-+ edit it by hand to track a package; `hints.nvchecker_keyfile` gives it a
-+ GitHub token) and ticks the outdated packages. **Update selected** rewrites
-+ their hints on the VM (version, URLs, md5 of the downloaded source; with
-+ **No download** on, `DOWNLOAD="no"` instead; a hint sbshadow wrote,
-+ `DELREQUIRES` only, gets the `.info` version lines added). **Delete hint**
-+ removes the ticked packages' hints, each kept as a `.bak`, revertable from
-+ the dialog that follows. neovim and openvino are refused for now: their
-+ bundled deps still need mkhint on the VM.
-+- **Results**: slackrepo's last build attempt per package, newest first,
-+ filterable by result. **Show build log** (or double-click) opens the end of
-+ the log of the package's last attempt (slackrepo keeps one per command:
-+ `build.log`, `update.log`, `rebuild.log`, `remove.log`), where it reports
-+ the failure, with its `resource.log` (load, memory, tmp over the build).
-+- **Jobs**: every job with its live log. **Pull overlay** (`sbpull <overlay>`)
-+ and **Check shadowing** (`sbshadow`) run as previews; when one finds
-+ `shadow:` or `unshadow:` lines, its dialog offers **Apply shadowing**.
-+ **Kill** stops the selected running job.
- - Real runs sign the repository. When the package signing key is locked
- (status bar 🔒), the GUI asks for its passphrase first and hands it to the
- VM's gpg-agent; it is never stored.
-diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
-index 775b0c4..b746044 100644
---- c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
-+++ i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
-@@ -136,7 +136,8 @@ New key `manifest_packages` (below). `remote/config.example.toml` shows it.
- built as `hint-new` does. The last log line is
- `SBB-RESULT {"ok": [pkg...], "failed": [[pkg, why]...]}`. Exit status 1
- when any package failed.
--- `hint-new <pkg> [<ver>] [--no-dl]`: refuses when the hint exists. Builds
-+- `hint-new <pkg> [<ver>] [--no-dl]` (since 0.6.0 `hint-new <pkg>... [--no-dl]`,
-+ no version, see the UI design): refuses when the hint exists. Builds
- the hint from the `.info` as mkhint does, without phantom deps (see the
- facts), then applies the version when given. No `.bak` (a stale
- one is removed); revert means delete.
-diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-index 7c7b254..8a6844d 100644
---- c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-+++ i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-@@ -70,7 +70,7 @@ Disabled reasons (tooltips):
- - Delete hint: "none of the ticked packages has a hint".
- - Show build log: "select a result with a log".
- - Run it: "select a finished preview".
--- Kill: "no job running".
-+- Kill: "select a running job".
- - Check upstream while running: "checking…".
-
- Update all, Publish, Pull overlay and Check shadowing are always enabled
-@@ -120,7 +120,8 @@ dialog, Build these -> preview dialog, Run it. No tab switch.
-
- - Starting a job no longer switches to Jobs; the user stays on the tab.
- - While a job runs, the Jobs tab shows a running icon (`system-run`) and
-- accent-colored text (palette highlight).
-+ reads "Jobs (running)": the tab style sheet's text color would override a
-+ per-tab color.
- - When it ends, the icon becomes the end status (`✔` ok, `✘` failed,
- killed, lost) and stays until the Jobs tab is shown, then clears.
- - The status bar job button stays.
-```
-
-- [ ] **Step 2: Commit and push.**
-
-```bash
-git add README.md AGENTS.md docs/superpowers/specs
-git commit -m "UI guidelines in AGENTS.md; README for the new frame
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
-git push
-```
-
-### Task 4: deploy (ask first)
-
-- [ ] `./install.sh --remote`, expected `{"version": "0.6.0", ...}`.
-- [ ] The user restarts sb-builder and runs the hugo-style flow: Hints, Check upstream, Update selected, Build these, Run it, all without switching tab.