synodic-client 0.0.1.dev36__tar.gz → 0.0.1.dev38__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/PKG-INFO +3 -3
  2. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/pyproject.toml +12 -4
  3. synodic_client-0.0.1.dev38/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/bootstrap.py +1 -1
  5. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/icon.py +5 -7
  6. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/__init__.py +15 -1
  7. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/action_card.py +83 -150
  8. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/card.py +1 -1
  9. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/install.py +469 -403
  10. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/log_panel.py +26 -20
  11. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/screen.py +35 -93
  12. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/spinner.py +1 -1
  13. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/tray.py +5 -1
  14. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/update_banner.py +54 -50
  15. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/theme.py +3 -0
  16. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/cli.py +1 -1
  17. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/config.py +8 -5
  18. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/logging.py +9 -1
  19. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/resolution.py +1 -1
  20. synodic_client-0.0.1.dev38/tests/unit/qt/conftest.py +24 -0
  21. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_action_card.py +110 -75
  22. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_install_preview.py +7 -158
  23. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_log_panel.py +16 -17
  24. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_logging.py +71 -0
  25. synodic_client-0.0.1.dev38/tests/unit/qt/test_preview_model.py +195 -0
  26. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_settings.py +4 -9
  27. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/test_update_banner.py +56 -28
  28. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_config.py +0 -16
  29. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_resolution.py +4 -16
  30. synodic_client-0.0.1.dev36/synodic_client/_version.py +0 -1
  31. synodic_client-0.0.1.dev36/tests/unit/qt/conftest.py +0 -10
  32. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/LICENSE.md +0 -0
  33. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/README.md +0 -0
  34. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/__init__.py +0 -0
  35. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/__main__.py +0 -0
  36. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/__init__.py +0 -0
  37. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/instance.py +0 -0
  38. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/qt.py +0 -0
  39. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/screen/settings.py +0 -0
  40. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/uri.py +0 -0
  41. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/application/workers.py +0 -0
  42. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/protocol.py +0 -0
  44. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/py.typed +0 -0
  45. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/startup.py +0 -0
  46. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/synodic_client/updater.py +0 -0
  47. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/conftest.py +0 -0
  49. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/qt/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_cli.py +0 -0
  52. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_client_updater.py +0 -0
  53. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_client_version.py +0 -0
  54. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_examples.py +0 -0
  55. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_install.py +0 -0
  56. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_updater.py +0 -0
  57. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/test_uri.py +0 -0
  58. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/windows/__init__.py +0 -0
  59. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/windows/conftest.py +0 -0
  60. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/windows/test_protocol.py +0 -0
  61. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev38}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev36
3
+ Version: 0.0.1.dev38
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -8,9 +8,9 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev52
11
+ Requires-Dist: porringer>=0.2.1.dev54
12
12
  Requires-Dist: qasync>=0.28.0
13
- Requires-Dist: velopack>=0.0.1442.dev64255
13
+ Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev52",
13
+ "porringer>=0.2.1.dev54",
14
14
  "qasync>=0.28.0",
15
- "velopack>=0.0.1442.dev64255",
15
+ "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev36"
18
+ version = "0.0.1.dev38"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -35,7 +35,7 @@ build = [
35
35
  "pyinstaller>=6.19.0",
36
36
  ]
37
37
  lint = [
38
- "ruff>=0.15.2",
38
+ "ruff>=0.15.3",
39
39
  "pyrefly>=0.54.0",
40
40
  ]
41
41
  test = [
@@ -75,6 +75,14 @@ select = [
75
75
  "PT",
76
76
  ]
77
77
 
78
+ [tool.ruff.lint.per-file-ignores]
79
+ "synodic_client/application/bootstrap.py" = [
80
+ "E402",
81
+ ]
82
+ "synodic_client/cli.py" = [
83
+ "PLC0415",
84
+ ]
85
+
78
86
  [tool.ruff.lint.pydocstyle]
79
87
  convention = "google"
80
88
 
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev38'
@@ -44,7 +44,7 @@ if not _dev_mode:
44
44
  remove_startup()
45
45
 
46
46
  # Heavy imports happen here — PySide6, porringer, etc.
47
- from synodic_client.application.qt import application # noqa: E402
47
+ from synodic_client.application.qt import application
48
48
 
49
49
  _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
50
50
  application(uri=_uri, dev_mode=_dev_mode)
@@ -4,13 +4,14 @@ Loads the icon once from package resources and caches the ``QIcon``
4
4
  so every caller shares the same instance.
5
5
  """
6
6
 
7
+ import functools
8
+
7
9
  from PySide6.QtGui import QIcon, QPixmap
8
10
 
9
11
  from synodic_client.client import Client
10
12
 
11
- _cached_icon: QIcon | None = None
12
-
13
13
 
14
+ @functools.cache
14
15
  def app_icon() -> QIcon:
15
16
  """Return the shared application ``QIcon``, loading it on first call.
16
17
 
@@ -20,8 +21,5 @@ def app_icon() -> QIcon:
20
21
  Returns:
21
22
  A ``QIcon`` backed by the application logo.
22
23
  """
23
- global _cached_icon # noqa: PLW0603
24
- if _cached_icon is None:
25
- with Client.resource(Client.icon) as icon_path:
26
- _cached_icon = QIcon(QPixmap(str(icon_path)))
27
- return _cached_icon
24
+ with Client.resource(Client.icon) as icon_path:
25
+ return QIcon(QPixmap(str(icon_path)))
@@ -6,7 +6,7 @@ execution log panel live here to avoid circular imports.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from porringer.schema import SkipReason
9
+ from porringer.schema import SetupAction, SkipReason
10
10
  from porringer.schema.plugin import PluginKind
11
11
 
12
12
  ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
@@ -54,3 +54,17 @@ def skip_reason_label(reason: SkipReason | None) -> str:
54
54
  if reason is None:
55
55
  return 'Skipped'
56
56
  return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
57
+
58
+
59
+ def format_cli_command(action: SetupAction) -> str:
60
+ """Return a human-readable CLI command string for *action*.
61
+
62
+ Prefers ``cli_command``, falls back to ``command``, then synthesises
63
+ an ``installer install <package>`` string for package actions, and
64
+ finally returns the action description as a last resort.
65
+ """
66
+ if parts := (action.cli_command or action.command):
67
+ return ' '.join(parts)
68
+ if action.kind == PluginKind.PACKAGE and action.package:
69
+ return f'{action.installer or "pip"} install {action.package}'
70
+ return action.description
@@ -1,9 +1,9 @@
1
1
  """Action card widgets for the install preview screen.
2
2
 
3
- Replaces the previous ``QTableWidget`` + ``ExecutionLogPanel`` layout
4
- with compact, self-contained cards one per setup action. Each card
5
- shows essential information (package name, type, version, status) and
6
- expands inline to display execution output during install.
3
+ Each card shows essential information (package name, type, version,
4
+ status badge). During install, execution output is routed to the
5
+ unified :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`
6
+ rather than displayed inline.
7
7
 
8
8
  :class:`ActionCard` is the per-action widget.
9
9
  :class:`ActionCardList` is the scrollable container that holds them.
@@ -11,34 +11,29 @@ expands inline to display execution output during install.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import html as html_mod
15
14
  import logging
16
15
 
17
16
  from porringer.backend.command.core.action_builder import PHASE_ORDER
18
17
  from porringer.schema import SetupAction, SetupActionResult, SkipReason
19
18
  from porringer.schema.plugin import PluginKind
20
19
  from PySide6.QtCore import QRect, Qt, QTimer, Signal
21
- from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor
20
+ from PySide6.QtGui import QColor, QPainter, QPen
22
21
  from PySide6.QtWidgets import (
23
22
  QApplication,
24
23
  QCheckBox,
25
24
  QFrame,
26
25
  QHBoxLayout,
27
26
  QLabel,
28
- QScrollArea,
29
- QSizePolicy,
30
- QTextEdit,
31
27
  QToolButton,
32
28
  QVBoxLayout,
33
29
  QWidget,
34
30
  )
35
31
 
36
- from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
32
+ from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label
37
33
  from synodic_client.application.theme import (
38
34
  ACTION_CARD_COMMAND_STYLE,
39
35
  ACTION_CARD_DESC_STYLE,
40
36
  ACTION_CARD_EXECUTING_STYLE,
41
- ACTION_CARD_LOG_STYLE,
42
37
  ACTION_CARD_PACKAGE_STYLE,
43
38
  ACTION_CARD_SKELETON_BAR_STYLE,
44
39
  ACTION_CARD_SKELETON_STYLE,
@@ -48,6 +43,7 @@ from synodic_client.application.theme import (
48
43
  ACTION_CARD_STATUS_DONE,
49
44
  ACTION_CARD_STATUS_FAILED,
50
45
  ACTION_CARD_STATUS_NEEDED,
46
+ ACTION_CARD_STATUS_PENDING,
51
47
  ACTION_CARD_STATUS_RUNNING,
52
48
  ACTION_CARD_STATUS_SATISFIED,
53
49
  ACTION_CARD_STATUS_SKIPPED,
@@ -60,13 +56,6 @@ from synodic_client.application.theme import (
60
56
  COPY_BTN_STYLE,
61
57
  COPY_FEEDBACK_MS,
62
58
  COPY_ICON,
63
- LOG_COLOR_ERROR,
64
- LOG_COLOR_PHASE,
65
- LOG_COLOR_STDERR,
66
- LOG_COLOR_STDOUT,
67
- LOG_COLOR_SUCCESS,
68
- MONOSPACE_FAMILY,
69
- MONOSPACE_SIZE,
70
59
  )
71
60
 
72
61
  logger = logging.getLogger(__name__)
@@ -113,12 +102,14 @@ def action_sort_key(action: SetupAction) -> int:
113
102
 
114
103
 
115
104
  def _format_command(action: SetupAction) -> str:
116
- """Return a short CLI command string for display."""
117
- if parts := (action.cli_command or action.command):
118
- return ' '.join(parts)
119
- if action.kind == PluginKind.PACKAGE and action.package:
120
- return f'{action.installer or "pip"} install {action.package}'
121
- return ''
105
+ """Return a short CLI command string for display.
106
+
107
+ Wraps :func:`~synodic_client.application.screen.format_cli_command`
108
+ but returns an empty string instead of the description fallback so
109
+ cards only show an explicit command line.
110
+ """
111
+ text = format_cli_command(action)
112
+ return '' if text == action.description else text
122
113
 
123
114
 
124
115
  # ---------------------------------------------------------------------------
@@ -138,7 +129,7 @@ class _CardSpinner(QWidget):
138
129
  self._angle = 0
139
130
  self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE)
140
131
 
141
- def paintEvent(self, _event: object) -> None: # noqa: N802
132
+ def paintEvent(self, _event: object) -> None:
142
133
  """Draw the muted track and animated highlight arc."""
143
134
  painter = QPainter(self)
144
135
  painter.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -211,7 +202,6 @@ class ActionCard(QFrame):
211
202
  self.setObjectName('actionCard')
212
203
  self._action: SetupAction | None = None
213
204
  self._is_skeleton = skeleton
214
- self._log_expanded = False
215
205
  self._checking = False
216
206
  self._check_available_version: str | None = None
217
207
 
@@ -275,7 +265,6 @@ class ActionCard(QFrame):
275
265
  def _init_real_ui(self) -> None:
276
266
  """Build the action card layout."""
277
267
  self.setStyleSheet(ACTION_CARD_STYLE)
278
- self.setCursor(Qt.CursorShape.PointingHandCursor)
279
268
 
280
269
  outer = QVBoxLayout(self)
281
270
  outer.setContentsMargins(6, 6, 6, 6)
@@ -284,7 +273,6 @@ class ActionCard(QFrame):
284
273
  outer.addLayout(self._build_top_row())
285
274
  outer.addWidget(self._build_description_row())
286
275
  outer.addWidget(self._build_command_row())
287
- outer.addWidget(self._build_log_output())
288
276
 
289
277
  def _build_top_row(self) -> QHBoxLayout:
290
278
  """Build the top row: type badge | package name ... version | status/spinner | prerelease."""
@@ -363,40 +351,10 @@ class ActionCard(QFrame):
363
351
  self._command_row.hide()
364
352
  return self._command_row
365
353
 
366
- def _build_log_output(self) -> QTextEdit:
367
- """Build the inline log body (hidden by default)."""
368
- self._log_output = QTextEdit()
369
- self._log_output.setReadOnly(True)
370
- self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE))
371
- self._log_output.setStyleSheet(ACTION_CARD_LOG_STYLE)
372
- self._log_output.setMinimumHeight(40)
373
- self._log_output.setMaximumHeight(250)
374
- self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
375
- self._log_output.hide()
376
- return self._log_output
377
-
378
354
  # ------------------------------------------------------------------
379
- # Mouse events (toggle log)
355
+ # Mouse events (copy button)
380
356
  # ------------------------------------------------------------------
381
357
 
382
- def mousePressEvent(self, event: object) -> None: # noqa: N802
383
- """Toggle the inline log body on click."""
384
- if self._is_skeleton or not hasattr(self, '_log_output'):
385
- return
386
- # Don't toggle the log when clicking interactive child widgets
387
- if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
388
- return
389
- if hasattr(self, '_package_label') and self._package_label.underMouse():
390
- return
391
- if hasattr(self, '_desc_label') and self._desc_label.underMouse():
392
- return
393
- self._toggle_log()
394
-
395
- def _toggle_log(self) -> None:
396
- """Expand or collapse the inline log body."""
397
- self._log_expanded = not self._log_expanded
398
- self._log_output.setVisible(self._log_expanded)
399
-
400
358
  def _copy_command(self) -> None:
401
359
  """Copy the command label text to the clipboard with brief feedback."""
402
360
  clipboard = QApplication.clipboard()
@@ -471,23 +429,8 @@ class ActionCard(QFrame):
471
429
 
472
430
  self._version_label.setText('')
473
431
 
474
- # Status — check plugin presence first
475
- installer_missing = (
476
- action.installer is not None
477
- and action.installer in plugin_installed
478
- and not plugin_installed[action.installer]
479
- )
480
-
481
- if installer_missing:
482
- self._status_label.setText('Not installed')
483
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
484
- self._status_label.show()
485
- else:
486
- # Show spinner instead of status text while checking
487
- self._status_label.hide()
488
- self._checking = True
489
- self._spinner_canvas.show()
490
- self._spinner_timer.start()
432
+ # Status
433
+ self._populate_status(action, plugin_installed)
491
434
 
492
435
  # Pre-release checkbox
493
436
  if action.package is not None:
@@ -525,6 +468,40 @@ class ActionCard(QFrame):
525
468
  else:
526
469
  self._command_row.hide()
527
470
 
471
+ def _populate_status(
472
+ self,
473
+ action: SetupAction,
474
+ plugin_installed: dict[str, bool],
475
+ ) -> None:
476
+ """Set the initial status badge during :meth:`populate`.
477
+
478
+ Bare-command actions (``kind is None``) show a static *Pending*
479
+ badge. Plugin-backed actions either flag a missing installer or
480
+ start the dry-run spinner.
481
+ """
482
+ if action.kind is None:
483
+ self._status_label.setText('Pending')
484
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING)
485
+ self._status_label.show()
486
+ return
487
+
488
+ installer_missing = (
489
+ action.installer is not None
490
+ and action.installer in plugin_installed
491
+ and not plugin_installed[action.installer]
492
+ )
493
+
494
+ if installer_missing:
495
+ self._status_label.setText('Not installed')
496
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
497
+ self._status_label.show()
498
+ else:
499
+ # Show spinner instead of status text while checking
500
+ self._status_label.hide()
501
+ self._checking = True
502
+ self._spinner_canvas.show()
503
+ self._spinner_timer.start()
504
+
528
505
  def initial_status(self) -> str:
529
506
  """Return the initial status text set during :meth:`populate`."""
530
507
  if self._is_skeleton or not hasattr(self, '_status_label'):
@@ -549,6 +526,15 @@ class ActionCard(QFrame):
549
526
  def set_check_result(self, result: SetupActionResult) -> None:
550
527
  """Update the card with a dry-run check result.
551
528
 
529
+ Handles four cases:
530
+
531
+ * **Skipped (update available)** — amber "Update available" badge.
532
+ * **Skipped (other)** — muted satisfied badge.
533
+ * **Failed** — red "Failed" badge with diagnostic tooltip.
534
+ This covers backend failures surfaced during the dry-run
535
+ (e.g. missing SCM plugin, unresolvable deferred action).
536
+ * **Needed** — default blue badge.
537
+
552
538
  Args:
553
539
  result: The action check result from the preview worker.
554
540
  """
@@ -565,6 +551,15 @@ class ActionCard(QFrame):
565
551
  label = skip_reason_label(result.skip_reason)
566
552
  self._status_label.setText(label)
567
553
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
554
+ elif not result.success:
555
+ label = 'Failed'
556
+ self._status_label.setText(label)
557
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
558
+ logger.warning(
559
+ 'Dry-run check failed for %s: %s',
560
+ self._action.description if self._action else '(unknown)',
561
+ result.message or 'unknown error',
562
+ )
568
563
  else:
569
564
  label = 'Needed'
570
565
  self._status_label.setText(label)
@@ -604,7 +599,8 @@ class ActionCard(QFrame):
604
599
  def set_executing(self) -> None:
605
600
  """Transition the card into the *executing* state.
606
601
 
607
- Shows the inline log body and updates the status badge.
602
+ Updates the status badge. Execution output is routed to the
603
+ unified :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`.
608
604
  """
609
605
  if self._is_skeleton:
610
606
  return
@@ -612,37 +608,13 @@ class ActionCard(QFrame):
612
608
  self.setStyleSheet(ACTION_CARD_EXECUTING_STYLE)
613
609
  self._status_label.setText('Running\u2026')
614
610
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_RUNNING)
615
- self._log_expanded = True
616
- self._log_output.setVisible(True)
617
-
618
- def append_output(self, text: str, stream: str | None = None) -> None:
619
- """Append a line of output to the inline log.
620
-
621
- Args:
622
- text: The output line.
623
- stream: ``'stdout'``, ``'stderr'``, or ``None`` for phase messages.
624
- """
625
- if self._is_skeleton or not hasattr(self, '_log_output'):
626
- return
627
-
628
- colour = LOG_COLOR_STDOUT
629
- if stream == 'stderr':
630
- colour = LOG_COLOR_STDERR
631
- elif stream is None:
632
- colour = LOG_COLOR_PHASE
633
-
634
- escaped = html_mod.escape(text)
635
- self._log_output.append(f'<span style="color: {colour};">{escaped}</span>')
636
-
637
- cursor = self._log_output.textCursor()
638
- cursor.movePosition(QTextCursor.MoveOperation.End)
639
- self._log_output.setTextCursor(cursor)
640
611
 
641
612
  def set_result(self, result: SetupActionResult) -> None:
642
613
  """Update the card with the final execution result.
643
614
 
644
- The card returns to the default border style. The log body stays
645
- visible but can be collapsed by clicking the card.
615
+ The card returns to the default border style. Detailed output
616
+ is displayed in the unified
617
+ :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`.
646
618
 
647
619
  Args:
648
620
  result: The action execution result.
@@ -656,14 +628,9 @@ class ActionCard(QFrame):
656
628
  label = skip_reason_label(result.skip_reason)
657
629
  self._status_label.setText(label)
658
630
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_SKIPPED)
659
- self.append_output(f'\u23ed Skipped: {label}', None)
660
631
  elif result.success:
661
632
  self._status_label.setText('Done')
662
633
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_DONE)
663
- msg = result.message or 'Completed successfully'
664
- self._log_output.append(
665
- f'<span style="color: {LOG_COLOR_SUCCESS};">\u2713 {html_mod.escape(msg)}</span>',
666
- )
667
634
  # Update version if an upgrade completed
668
635
  new_version = result.available_version or self._check_available_version
669
636
  if new_version:
@@ -672,10 +639,6 @@ class ActionCard(QFrame):
672
639
  else:
673
640
  self._status_label.setText('Failed')
674
641
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
675
- msg = result.message or 'Unknown error'
676
- self._log_output.append(
677
- f'<span style="color: {LOG_COLOR_ERROR};">\u2717 {html_mod.escape(msg)}</span>',
678
- )
679
642
 
680
643
  # ------------------------------------------------------------------
681
644
  # Public API — status text accessors (for counting)
@@ -695,15 +658,12 @@ class ActionCard(QFrame):
695
658
 
696
659
 
697
660
  # ---------------------------------------------------------------------------
698
- # ActionCardList — scrollable container
661
+ # ActionCardList — card container
699
662
  # ---------------------------------------------------------------------------
700
663
 
701
664
 
702
- class ActionCardList(QScrollArea):
703
- """Scrollable container of :class:`ActionCard` widgets.
704
-
705
- Replaces both the ``QTableWidget`` and the ``ExecutionLogPanel`` from
706
- the previous install screen layout. One scrollbar, no nesting.
665
+ class ActionCardList(QWidget):
666
+ """Container of :class:`ActionCard` widgets.
707
667
 
708
668
  Cards are keyed by :func:`action_key` (content-based) so that
709
669
  look-ups work across different ``execute_stream`` runs where the
@@ -716,18 +676,12 @@ class ActionCardList(QScrollArea):
716
676
  def __init__(self, parent: QWidget | None = None) -> None:
717
677
  """Initialise the card list."""
718
678
  super().__init__(parent)
719
- self.setWidgetResizable(True)
720
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
721
- self.setFrameShape(QFrame.Shape.NoFrame)
722
679
 
723
- self._container = QWidget()
724
- self._layout = QVBoxLayout(self._container)
680
+ self._layout = QVBoxLayout(self)
725
681
  self._layout.setContentsMargins(0, 0, 0, 0)
726
682
  self._layout.setSpacing(ACTION_CARD_SPACING)
727
683
  self._layout.addStretch()
728
684
 
729
- self.setWidget(self._container)
730
-
731
685
  self._cards: list[ActionCard] = []
732
686
  self._action_map: dict[tuple[object, ...], ActionCard] = {}
733
687
 
@@ -745,7 +699,7 @@ class ActionCardList(QScrollArea):
745
699
  """
746
700
  self.clear()
747
701
  for _ in range(count):
748
- card = ActionCard(self._container, skeleton=True)
702
+ card = ActionCard(self, skeleton=True)
749
703
  self._layout.insertWidget(self._layout.count() - 1, card)
750
704
  self._cards.append(card)
751
705
 
@@ -768,12 +722,9 @@ class ActionCardList(QScrollArea):
768
722
  prerelease_overrides: Package names with user pre-release overrides.
769
723
  """
770
724
  self.clear()
771
- sorted_actions = sorted(
772
- (a for a in actions if a.kind is not None),
773
- key=action_sort_key,
774
- )
725
+ sorted_actions = sorted(actions, key=action_sort_key)
775
726
  for act in sorted_actions:
776
- card = ActionCard(self._container)
727
+ card = ActionCard(self)
777
728
  card.populate(
778
729
  act,
779
730
  plugin_installed=plugin_installed,
@@ -829,21 +780,3 @@ class ActionCardList(QScrollArea):
829
780
  card.deleteLater()
830
781
  self._cards.clear()
831
782
  self._action_map.clear()
832
-
833
- # ------------------------------------------------------------------
834
- # Scroll helpers
835
- # ------------------------------------------------------------------
836
-
837
- def scroll_to_card(self, card: ActionCard) -> None:
838
- """Ensure *card* is visible in the scroll area."""
839
- self.ensureWidgetVisible(card)
840
-
841
- def scroll_to_card_bottom(self, card: ActionCard) -> None:
842
- """Scroll so the bottom of *card* is visible.
843
-
844
- Used during execution to follow the growing inline log output.
845
- Unlike :meth:`scroll_to_card` (which may show the top of a tall
846
- card), this always brings the bottom edge into view.
847
- """
848
- bottom_y = card.geometry().bottom()
849
- self.ensureVisible(0, bottom_y, 0, 50)
@@ -64,7 +64,7 @@ class ClickableHeader(QWidget):
64
64
 
65
65
  # --- Event handling ---------------------------------------------------
66
66
 
67
- def mousePressEvent(self, _event: object) -> None: # noqa: N802
67
+ def mousePressEvent(self, _event: object) -> None:
68
68
  """Emit :attr:`clicked` on any mouse press."""
69
69
  self.clicked.emit()
70
70