synodic-client 0.0.1.dev75__tar.gz → 0.0.1.dev77__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 (106) hide show
  1. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/PKG-INFO +3 -3
  2. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/pyproject.toml +4 -4
  3. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/bootstrap.py +1 -0
  4. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/config_store.py +4 -3
  5. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/debug.py +1 -1
  6. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/package_state.py +23 -0
  7. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/action_card.py +57 -1
  8. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/install.py +195 -80
  9. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/install_workers.py +71 -16
  10. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/projects.py +30 -12
  11. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/schema.py +21 -10
  12. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/screen.py +71 -26
  13. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/tool_update_controller.py +8 -7
  14. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/theme.py +16 -0
  15. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/__init__.py +2 -0
  16. synodic_client-0.0.1.dev77/synodic_client/cli/install.py +213 -0
  17. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/output.py +5 -3
  18. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/tool.py +4 -0
  19. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/__init__.py +14 -1
  20. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/config.py +23 -0
  21. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/install.py +180 -15
  22. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/schema.py +229 -10
  23. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/tool.py +31 -0
  24. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_config.py +37 -1
  25. synodic_client-0.0.1.dev77/tests/unit/operations/test_install.py +424 -0
  26. synodic_client-0.0.1.dev77/tests/unit/operations/test_install_plan.py +207 -0
  27. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_tool.py +21 -0
  28. synodic_client-0.0.1.dev77/tests/unit/qt/test_package_state.py +63 -0
  29. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_preview_model.py +61 -15
  30. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_cli.py +107 -0
  31. synodic_client-0.0.1.dev75/tests/unit/operations/test_install.py +0 -221
  32. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/LICENSE.md +0 -0
  33. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/README.md +0 -0
  34. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/__init__.py +0 -0
  35. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/__main__.py +0 -0
  36. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/__init__.py +0 -0
  37. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/data.py +0 -0
  38. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/icon.py +0 -0
  39. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/init.py +0 -0
  40. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/instance.py +0 -0
  41. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/qt.py +0 -0
  42. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/schema.py +0 -0
  43. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/card.py +0 -0
  45. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/log_panel.py +0 -0
  46. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/plugin_row.py +0 -0
  47. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/settings.py +0 -0
  48. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/sidebar.py +0 -0
  49. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/spinner.py +0 -0
  50. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/tray.py +0 -0
  51. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/update_banner.py +0 -0
  52. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/update_controller.py +0 -0
  53. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/update_model.py +0 -0
  54. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/uri.py +0 -0
  55. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/application/workers.py +0 -0
  56. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/config.py +0 -0
  57. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/context.py +0 -0
  58. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/debug.py +0 -0
  59. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/project.py +0 -0
  60. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/cli/update.py +0 -0
  61. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/client.py +0 -0
  62. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/config.py +0 -0
  63. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/logging.py +0 -0
  64. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/bootstrap.py +0 -0
  65. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/project.py +0 -0
  66. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/operations/update.py +0 -0
  67. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/protocol.py +0 -0
  68. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/py.typed +0 -0
  69. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/resolution.py +0 -0
  70. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/schema.py +0 -0
  71. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/startup.py +0 -0
  72. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/subprocess_patch.py +0 -0
  73. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/synodic_client/updater.py +0 -0
  74. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/__init__.py +0 -0
  75. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/conftest.py +0 -0
  76. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/__init__.py +0 -0
  77. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/operations/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_project.py +0 -0
  79. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_update.py +0 -0
  80. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/__init__.py +0 -0
  81. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/conftest.py +0 -0
  82. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_action_card.py +0 -0
  83. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_gather_packages.py +0 -0
  84. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_install_preview.py +0 -0
  85. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_log_panel.py +0 -0
  86. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_logging.py +0 -0
  87. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_settings.py +0 -0
  88. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_sidebar.py +0 -0
  89. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_tray_window_show.py +0 -0
  90. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_banner.py +0 -0
  91. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_controller.py +0 -0
  92. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_feedback.py +0 -0
  93. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_client_updater.py +0 -0
  94. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_client_version.py +0 -0
  95. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_config.py +0 -0
  96. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_examples.py +0 -0
  97. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_init.py +0 -0
  98. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_install.py +0 -0
  99. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_resolution.py +0 -0
  100. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_updater.py +0 -0
  101. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_uri.py +0 -0
  102. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/test_workers.py +0 -0
  103. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/windows/__init__.py +0 -0
  104. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/windows/conftest.py +0 -0
  105. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/tests/unit/windows/test_protocol.py +0 -0
  106. {synodic_client-0.0.1.dev75 → synodic_client-0.0.1.dev77}/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.dev75
3
+ Version: 0.0.1.dev77
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.dev85
11
+ Requires-Dist: porringer>=0.2.1.dev86
12
12
  Requires-Dist: qasync>=0.28.0
13
- Requires-Dist: velopack>=0.0.1444.dev49733
13
+ Requires-Dist: velopack>=0.0.1521.dev61717
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.dev85",
13
+ "porringer>=0.2.1.dev86",
14
14
  "qasync>=0.28.0",
15
- "velopack>=0.0.1444.dev49733",
15
+ "velopack>=0.0.1521.dev61717",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev75"
18
+ version = "0.0.1.dev77"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -36,7 +36,7 @@ build = [
36
36
  ]
37
37
  lint = [
38
38
  "ruff>=0.15.6",
39
- "pyrefly>=0.56.0",
39
+ "pyrefly>=0.57.0",
40
40
  ]
41
41
  test = [
42
42
  "pytest>=9.0.2",
@@ -55,6 +55,7 @@ def bootstrap() -> None:
55
55
  run_startup_preamble(sys.executable)
56
56
 
57
57
  # Heavy imports happen here — PySide6, porringer, etc.
58
+
58
59
  from synodic_client.application.qt import application
59
60
 
60
61
  application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug)
@@ -10,7 +10,8 @@ from __future__ import annotations
10
10
 
11
11
  from PySide6.QtCore import QObject, Signal
12
12
 
13
- from synodic_client.resolution import ResolvedConfig, resolve_config, update_user_config
13
+ from synodic_client.operations.config import get_config, update_config
14
+ from synodic_client.schema import ResolvedConfig
14
15
 
15
16
 
16
17
  class ConfigStore(QObject):
@@ -33,7 +34,7 @@ class ConfigStore(QObject):
33
34
  def __init__(self, config: ResolvedConfig | None = None, parent: QObject | None = None) -> None:
34
35
  """Create a new store, optionally seeded with *config*."""
35
36
  super().__init__(parent)
36
- self._config = config if config is not None else resolve_config()
37
+ self._config = config if config is not None else get_config()
37
38
 
38
39
  @property
39
40
  def config(self) -> ResolvedConfig:
@@ -52,7 +53,7 @@ class ConfigStore(QObject):
52
53
  Returns:
53
54
  The fresh :class:`ResolvedConfig`.
54
55
  """
55
- self._config = update_user_config(**changes)
56
+ self._config = update_config(**changes)
56
57
  self.changed.emit(self._config)
57
58
  return self._config
58
59
 
@@ -227,7 +227,7 @@ class DebugHandler:
227
227
  needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed')
228
228
  satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied')
229
229
  pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending')
230
- upgradable = len(model.upgradable_keys)
230
+ upgradable = sum(1 for s in model.action_states if s.status == 'Update available')
231
231
 
232
232
  return json.dumps({
233
233
  'path': str(target),
@@ -123,3 +123,26 @@ class PackageStateStore(QObject):
123
123
  def clear(self) -> None:
124
124
  """Remove all recorded state."""
125
125
  self._data.clear()
126
+
127
+ def record_updates_completed(
128
+ self,
129
+ signal_key: str,
130
+ version_map: dict[str, tuple[str, str]],
131
+ ) -> None:
132
+ """Mark packages as updated, clearing stale ``has_update`` flags.
133
+
134
+ Called after a successful tool update run. For each entry in
135
+ *version_map* (``{package_name: (old_version, new_version)}``),
136
+ the corresponding :class:`PackageState` is updated to reflect
137
+ the new installed version and ``has_update`` is cleared.
138
+ """
139
+ changed = False
140
+ bucket = self._data.get(signal_key, {})
141
+ for pkg_name, (_, new_ver) in version_map.items():
142
+ existing = bucket.get(pkg_name)
143
+ if existing is not None:
144
+ existing.installed_version = new_ver
145
+ existing.has_update = False
146
+ changed = True
147
+ if changed:
148
+ self.state_changed.emit()
@@ -17,11 +17,12 @@ from porringer.backend.command.core.action_builder import PHASE_ORDER
17
17
  from porringer.schema import SetupAction, SetupActionResult
18
18
  from porringer.schema.plugin import PluginKind
19
19
  from PySide6.QtCore import Qt, QTimer, Signal
20
- from PySide6.QtGui import QColor
20
+ from PySide6.QtGui import QColor, QCursor, QMouseEvent
21
21
  from PySide6.QtWidgets import (
22
22
  QApplication,
23
23
  QCheckBox,
24
24
  QFrame,
25
+ QGraphicsOpacityEffect,
25
26
  QHBoxLayout,
26
27
  QLabel,
27
28
  QToolButton,
@@ -57,6 +58,7 @@ from synodic_client.application.theme import (
57
58
  ACTION_CARD_STATUS_UPDATE,
58
59
  ACTION_CARD_STYLE,
59
60
  ACTION_CARD_TYPE_BADGE_STYLE,
61
+ ACTION_CARD_UPDATE_AVAILABLE_STYLE,
60
62
  ACTION_CARD_VERSION_STYLE,
61
63
  COPY_BTN_SIZE,
62
64
  COPY_BTN_STYLE,
@@ -119,6 +121,10 @@ class ActionCard(QFrame):
119
121
  """Emitted with ``(package_name, checked)`` when the user toggles the
120
122
  per-row pre-release checkbox."""
121
123
 
124
+ navigate_to_tool = Signal(str, str)
125
+ """Emitted with ``(installer, package_name)`` when the user clicks an
126
+ 'Update available' card to navigate to the Tools view."""
127
+
122
128
  def __init__(
123
129
  self,
124
130
  parent: QWidget | None = None,
@@ -491,6 +497,10 @@ class ActionCard(QFrame):
491
497
  else:
492
498
  self._status_label.setToolTip('')
493
499
 
500
+ # "Update available" — fade the card and make it clickable
501
+ if status == 'Update available':
502
+ self._apply_update_available_style()
503
+
494
504
  # CLI command — update with resolved cli_command from result
495
505
  assert self._action is not None
496
506
  cmd_text = format_cli_command(self._action, result=result, suppress_description=True)
@@ -524,6 +534,15 @@ class ActionCard(QFrame):
524
534
  self._version_label.setText(f'\u2192 {result.available_version}')
525
535
  self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;')
526
536
 
537
+ def _apply_update_available_style(self) -> None:
538
+ """Fade the card and make it clickable for 'Update available' status."""
539
+ self.setStyleSheet(ACTION_CARD_UPDATE_AVAILABLE_STYLE)
540
+ opacity = QGraphicsOpacityEffect(self)
541
+ opacity.setOpacity(0.55)
542
+ self.setGraphicsEffect(opacity)
543
+ self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
544
+ self.setToolTip('Manage this update in the Tools view')
545
+
527
546
  def finalize_checking(self) -> None:
528
547
  """Resolve a still-pending 'Checking\u2026' status to 'Needed'.
529
548
 
@@ -605,6 +624,16 @@ class ActionCard(QFrame):
605
624
  """Return whether the card shows an 'Update available' status."""
606
625
  return self.status_text() == 'Update available'
607
626
 
627
+ def mousePressEvent(self, event: QMouseEvent) -> None:
628
+ """Navigate to Tools view when clicking an 'Update available' card."""
629
+ if self.is_update_available() and self._action is not None:
630
+ installer = self._action.installer or ''
631
+ package = str(self._action.package.name) if self._action.package else ''
632
+ if installer and package:
633
+ self.navigate_to_tool.emit(installer, package)
634
+ return
635
+ super().mousePressEvent(event)
636
+
608
637
 
609
638
  # ---------------------------------------------------------------------------
610
639
  # ActionCardList — card container
@@ -621,6 +650,13 @@ class ActionCardList(QWidget):
621
650
  prerelease_toggled = Signal(str, bool)
622
651
  """Forwarded from child :class:`ActionCard` widgets."""
623
652
 
653
+ navigate_to_tool = Signal(str, str)
654
+ """Forwarded from child :class:`ActionCard` widgets.
655
+
656
+ Emitted with ``(installer, package_name)`` when the user clicks an
657
+ 'Update available' card.
658
+ """
659
+
624
660
  def __init__(self, parent: QWidget | None = None) -> None:
625
661
  """Initialise the card list."""
626
662
  super().__init__(parent)
@@ -632,6 +668,7 @@ class ActionCardList(QWidget):
632
668
 
633
669
  self._cards: list[ActionCard] = []
634
670
  self._action_map: dict[SetupAction, ActionCard] = {}
671
+ self._index_map: dict[int, ActionCard] = {}
635
672
 
636
673
  # ------------------------------------------------------------------
637
674
  # Skeleton loading
@@ -679,10 +716,19 @@ class ActionCardList(QWidget):
679
716
  prerelease_overrides=prerelease_overrides,
680
717
  )
681
718
  card.prerelease_toggled.connect(self.prerelease_toggled.emit)
719
+ card.navigate_to_tool.connect(self.navigate_to_tool.emit)
682
720
  self._layout.insertWidget(self._layout.count() - 1, card)
683
721
  self._cards.append(card)
684
722
  self._action_map[act] = card
685
723
 
724
+ # Build original-index → card mapping so callers can look up by
725
+ # the action index porringer emits, which is independent of the
726
+ # display sort order.
727
+ for original_index, act in enumerate(actions):
728
+ card = self._action_map.get(act)
729
+ if card is not None:
730
+ self._index_map[original_index] = card
731
+
686
732
  # ------------------------------------------------------------------
687
733
  # Card lookup
688
734
  # ------------------------------------------------------------------
@@ -697,6 +743,15 @@ class ActionCardList(QWidget):
697
743
  """Return the number of cards (including skeletons)."""
698
744
  return len(self._cards)
699
745
 
746
+ def card_for_action_index(self, action_index: int) -> ActionCard | None:
747
+ """Return the card for the given original action index.
748
+
749
+ The index corresponds to the action's position in the unsorted
750
+ list passed to :meth:`populate`, matching the indices emitted
751
+ by porringer's ``ActionCompletedEvent``.
752
+ """
753
+ return self._index_map.get(action_index)
754
+
700
755
  def get_card(self, action: SetupAction) -> ActionCard | None:
701
756
  """Look up the card for a given action.
702
757
 
@@ -727,3 +782,4 @@ class ActionCardList(QWidget):
727
782
  card.deleteLater()
728
783
  self._cards.clear()
729
784
  self._action_map.clear()
785
+ self._index_map.clear()
@@ -23,9 +23,7 @@ from porringer.schema import (
23
23
  SetupAction,
24
24
  SetupActionResult,
25
25
  SetupResults,
26
- SkipReason,
27
26
  SubActionProgress,
28
- SyncStrategy,
29
27
  )
30
28
  from PySide6.QtCore import Qt, QTimer, Signal
31
29
  from PySide6.QtGui import QShowEvent
@@ -46,7 +44,7 @@ from PySide6.QtWidgets import (
46
44
  from synodic_client.application.package_state import PackageStateStore
47
45
  from synodic_client.application.screen.action_card import ActionCardList
48
46
  from synodic_client.application.screen.card import CardFrame
49
- from synodic_client.application.screen.install_workers import run_install, run_preview
47
+ from synodic_client.application.screen.install_workers import run_install, run_post_sync, run_preview
50
48
  from synodic_client.application.screen.log_panel import ExecutionLogPanel
51
49
  from synodic_client.application.screen.schema import (
52
50
  ActionState,
@@ -106,6 +104,10 @@ class SetupPreviewWidget(QWidget):
106
104
  #: Emitted whenever the lifecycle phase changes.
107
105
  phase_changed = Signal(object) # PreviewPhase
108
106
 
107
+ #: Emitted with ``(installer, package_name)`` when the user clicks an
108
+ #: 'Update available' card to navigate to the Tools view.
109
+ navigate_to_tool_requested = Signal(str, str)
110
+
109
111
  def __init__(
110
112
  self,
111
113
  porringer: API,
@@ -134,6 +136,7 @@ class SetupPreviewWidget(QWidget):
134
136
 
135
137
  self._model = PreviewModel()
136
138
  self._task: asyncio.Task[None] | None = None
139
+ self._install_results: SetupResults | None = None
137
140
 
138
141
  # Debounce timer for per-row pre-release checkbox changes
139
142
  self._prerelease_debounce = QTimer(self)
@@ -183,6 +186,7 @@ class SetupPreviewWidget(QWidget):
183
186
 
184
187
  self._card_list = ActionCardList()
185
188
  self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
189
+ self._card_list.navigate_to_tool.connect(self.navigate_to_tool_requested.emit)
186
190
  scroll_layout.addWidget(self._card_list)
187
191
 
188
192
  self._log_panel = ExecutionLogPanel()
@@ -249,6 +253,13 @@ class SetupPreviewWidget(QWidget):
249
253
  button_bar = QHBoxLayout()
250
254
  button_bar.addStretch()
251
255
 
256
+ self._run_commands_btn = QPushButton('Run Commands')
257
+ self._run_commands_btn.setToolTip('Execute post-sync commands from the manifest')
258
+ self._run_commands_btn.setEnabled(False)
259
+ self._run_commands_btn.hide()
260
+ self._run_commands_btn.clicked.connect(self._on_run_commands)
261
+ button_bar.addWidget(self._run_commands_btn)
262
+
252
263
  self._install_btn = QPushButton('Install')
253
264
  self._install_btn.setEnabled(False)
254
265
  self._install_btn.clicked.connect(self._on_install)
@@ -370,6 +381,9 @@ class SetupPreviewWidget(QWidget):
370
381
  self._status_label.setText('')
371
382
  self._status_label.setStyleSheet('')
372
383
  self._install_btn.setEnabled(False)
384
+ self._run_commands_btn.setEnabled(False)
385
+ self._run_commands_btn.hide()
386
+ self._install_results = None
373
387
  self._log_panel.clear()
374
388
  self._log_panel.hide()
375
389
 
@@ -517,6 +531,7 @@ class SetupPreviewWidget(QWidget):
517
531
  on_progress=self._on_action_progress,
518
532
  ),
519
533
  plugins=self._discovered_plugins,
534
+ exclude_post_sync=self._model.has_post_sync,
520
535
  )
521
536
  self._on_install_finished(results)
522
537
  except asyncio.CancelledError:
@@ -614,20 +629,24 @@ class SetupPreviewWidget(QWidget):
614
629
  self.metadata_ready.emit(preview)
615
630
 
616
631
  def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None:
617
- """Update the model and action card with a dry-run result."""
618
- m = self._model
619
- label = status
632
+ """Update the model and action card with a dry-run result.
633
+
634
+ This callback performs only two things:
635
+ 1. Update the ``ActionState.status`` in the model.
636
+ 2. Update the ``ActionCard`` widget visually.
620
637
 
621
- if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE and 0 <= row < len(m.action_states):
622
- m.upgradable_keys.add(m.action_states[row].action)
638
+ Cross-component side effects (PackageStateStore writes) are
639
+ deferred to :meth:`_on_preview_finished` for one-way data flow.
640
+ """
641
+ m = self._model
623
642
 
624
643
  if 0 <= row < len(m.action_states):
625
- m.action_states[row].status = label
644
+ m.action_states[row].status = status
626
645
 
627
646
  logger.debug(
628
647
  'Action checked [%d]: status=%s success=%s skipped=%s skip_reason=%s installed=%s available=%s',
629
648
  row,
630
- label,
649
+ status,
631
650
  result.success,
632
651
  result.skipped,
633
652
  result.skip_reason,
@@ -637,21 +656,10 @@ class SetupPreviewWidget(QWidget):
637
656
 
638
657
  # Update the card widget
639
658
  if m.preview and 0 <= row < len(m.preview.actions):
640
- action = m.preview.actions[row]
641
- card = self._card_list.get_card(action)
659
+ card = self._card_list.card_for_action_index(row)
642
660
  if card is not None:
643
661
  card.set_check_result(result, status)
644
662
 
645
- # Record in shared store so ToolsView can reflect the update
646
- if self._package_store is not None and action.installer and action.package:
647
- self._package_store.record_action_result(
648
- action.installer,
649
- str(action.package.name),
650
- installed_version=result.installed_version or '',
651
- available_version=result.available_version or '',
652
- has_update=result.skip_reason == SkipReason.UPDATE_AVAILABLE,
653
- )
654
-
655
663
  # Update phase text
656
664
  m.checked_count += 1
657
665
  total = len(m.action_states)
@@ -660,7 +668,15 @@ class SetupPreviewWidget(QWidget):
660
668
  )
661
669
 
662
670
  def _on_preview_finished(self) -> None:
663
- """Finalize the preview after the dry-run check completes."""
671
+ """Finalize the preview after the dry-run check completes.
672
+
673
+ Computes the :class:`InstallPlan` via the operations layer,
674
+ batch-writes to :class:`PackageStateStore`, and updates all
675
+ button states. This is the single point where preview results
676
+ are materialised into actionable decisions.
677
+ """
678
+ from synodic_client.operations.schema import ActionCheckResult, compute_install_plan
679
+
664
680
  m = self._model
665
681
  if not m.action_states:
666
682
  return
@@ -679,50 +695,60 @@ class SetupPreviewWidget(QWidget):
679
695
  finalized,
680
696
  )
681
697
 
682
- # Compute summary
683
- total = len(m.action_states)
684
- needed = sum(1 for s in m.action_states if s.status == 'Needed')
685
- upgradable = len(m.upgradable_keys)
686
- unavailable = sum(1 for s in m.action_states if s.status == 'Not installed')
687
- failed = sum(1 for s in m.action_states if s.status == 'Failed')
688
- pending = sum(1 for s in m.action_states if s.status == 'Pending')
689
- ready = sum(1 for s in m.action_states if s.status == 'Ready')
690
- satisfied = total - needed - upgradable - unavailable - failed - pending - ready
691
-
692
- parts: list[str] = []
693
- _counts: list[tuple[int, str]] = [
694
- (needed, 'needed'),
695
- (upgradable, 'upgradable'),
696
- (satisfied, 'already satisfied'),
697
- (ready, 'ready'),
698
- (pending, 'pending'),
699
- (unavailable, 'unavailable (plugin not installed)'),
700
- (failed, 'failed'),
701
- ]
702
- for count, label in _counts:
703
- if count:
704
- parts.append(f'{count} {label}')
705
-
706
- actionable = needed + upgradable
707
- if actionable == 0 and unavailable == 0 and failed == 0:
708
- self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.')
709
- self._install_btn.setEnabled(False)
698
+ # Build check results for the plan computation
699
+ check_results: list[ActionCheckResult] = []
700
+ for i, state in enumerate(m.action_states):
701
+ # We need the dry-run result — reconstruct a minimal one from the status
702
+ # The actual result was already applied to the card; here we use the
703
+ # status string which is the canonical output of resolve_action_status.
704
+ check_results.append(
705
+ ActionCheckResult(
706
+ index=i,
707
+ action=state.action,
708
+ result=SetupActionResult(action=state.action, success=True),
709
+ status=state.status,
710
+ ),
711
+ )
712
+
713
+ plan = compute_install_plan(check_results)
714
+ m.install_plan = plan
715
+
716
+ # Batch-write to PackageStateStore (one-way, after plan is computed)
717
+ if self._package_store is not None and m.preview is not None:
718
+ for state in m.action_states:
719
+ action = state.action
720
+ if action.installer and action.package:
721
+ self._package_store.record_action_result(
722
+ action.installer,
723
+ str(action.package.name),
724
+ installed_version='',
725
+ available_version='',
726
+ has_update=state.status == 'Update available',
727
+ )
728
+
729
+ # Update UI from the plan
730
+ self._status_label.setText(plan.summary)
731
+ self._install_btn.setEnabled(plan.install_enabled)
732
+ if not plan.install_enabled:
733
+ self._install_btn.setToolTip('No packages to install')
710
734
  else:
711
- self._status_label.setText(f'{total} action(s): {", ".join(parts)}.')
735
+ self._install_btn.setToolTip('')
736
+
737
+ # Show/enable the Run Commands button if post-sync exists
738
+ if plan.has_post_sync:
739
+ self._run_commands_btn.show()
740
+ self._run_commands_btn.setEnabled(True)
712
741
 
713
742
  self._set_phase(PreviewPhase.READY)
714
743
 
715
744
  logger.info(
716
- 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, '
717
- '%d ready, %d pending, %d unavailable, %d failed',
718
- total,
719
- needed,
720
- upgradable,
721
- satisfied,
722
- ready,
723
- pending,
724
- unavailable,
725
- failed,
745
+ 'Preview complete: %d total, %d to install, %d satisfied, %d upgradable, %d post-sync, install_enabled=%s',
746
+ len(m.action_states),
747
+ len(plan.install_indices),
748
+ len(plan.satisfied_indices),
749
+ len(plan.upgradable_indices),
750
+ len(plan.post_sync_indices),
751
+ plan.install_enabled,
726
752
  )
727
753
 
728
754
  def _on_preview_error(self, message: str) -> None:
@@ -769,14 +795,20 @@ class SetupPreviewWidget(QWidget):
769
795
  # --- Install execution ---
770
796
 
771
797
  def _on_install(self) -> None:
772
- """Handle the Install button click."""
798
+ """Handle the Install button click.
799
+
800
+ Uses the pre-computed :class:`InstallPlan` to determine the
801
+ sync strategy. Post-sync commands are excluded from this
802
+ execution — they are handled by :meth:`_on_run_commands`.
803
+ """
773
804
  m = self._model
774
- if m.manifest_path is None:
805
+ if m.manifest_path is None or m.install_plan is None:
775
806
  return
776
807
 
777
808
  self._prerelease_debounce.stop()
778
809
  self._set_phase(PreviewPhase.INSTALLING)
779
810
  self._install_btn.setEnabled(False)
811
+ self._run_commands_btn.setEnabled(False)
780
812
  self._close_btn.setEnabled(False)
781
813
  m.completed_count = 0
782
814
 
@@ -786,9 +818,8 @@ class SetupPreviewWidget(QWidget):
786
818
  self._log_panel.clear()
787
819
  self._log_panel.show()
788
820
 
789
- # Choose LATEST strategy when there are upgradable actions so
790
- # porringer actually upgrades the already-installed packages.
791
- strategy = SyncStrategy.LATEST if m.upgradable_keys else SyncStrategy.MINIMAL
821
+ # Strategy and post-sync exclusion come from the plan
822
+ strategy = m.install_plan.strategy
792
823
 
793
824
  self._task = asyncio.create_task(
794
825
  self._run_install_task(
@@ -847,25 +878,39 @@ class SetupPreviewWidget(QWidget):
847
878
  self._task.cancel()
848
879
 
849
880
  def _on_install_finished(self, results: SetupResults) -> None:
850
- """Handle install completion."""
851
- self._set_phase(PreviewPhase.DONE)
881
+ """Handle install completion.
852
882
 
853
- succeeded = sum(1 for r in results.results if r.success and not r.skipped)
854
- skipped = sum(1 for r in results.results if r.skipped)
883
+ If the plan includes post-sync commands and no actions failed,
884
+ automatically triggers :meth:`_on_run_commands`.
885
+ """
886
+ from synodic_client.operations.schema import format_install_summary
887
+
888
+ m = self._model
889
+ pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0
855
890
  failed = sum(1 for r in results.results if not r.success)
856
891
 
857
- parts = []
858
- if succeeded:
859
- parts.append(f'{succeeded} succeeded')
860
- if skipped:
861
- parts.append(f'{skipped} skipped')
862
- if failed:
863
- parts.append(f'{failed} failed')
892
+ # Auto-run post-sync if install succeeded and post-sync exists
893
+ if m.has_post_sync and failed == 0:
894
+ summary = format_install_summary(
895
+ install_results=list(results.results),
896
+ pre_skipped_count=pre_skipped,
897
+ )
898
+ self._status_label.setText(f'{summary}. Running post-sync commands\u2026')
899
+ self._run_commands_btn.setEnabled(False)
900
+ self._task = asyncio.create_task(self._run_post_sync_task())
901
+ self._install_results = results # Stash for final summary
902
+ return
864
903
 
865
- summary = ', '.join(parts) if parts else 'No actions executed.'
866
- self._status_label.setText(f'Done \u2014 {summary}')
904
+ self._set_phase(PreviewPhase.DONE)
905
+ summary = format_install_summary(
906
+ install_results=list(results.results),
907
+ pre_skipped_count=pre_skipped,
908
+ )
909
+ self._status_label.setText(summary)
867
910
  self._install_btn.setEnabled(False)
868
911
  self._close_btn.setEnabled(True)
912
+ if m.has_post_sync:
913
+ self._run_commands_btn.setEnabled(True)
869
914
  self.install_finished.emit(results)
870
915
 
871
916
  def _on_install_error(self, message: str) -> None:
@@ -875,6 +920,76 @@ class SetupPreviewWidget(QWidget):
875
920
  self._install_btn.setEnabled(True)
876
921
  self._close_btn.setEnabled(True)
877
922
 
923
+ # --- Post-sync execution ---
924
+
925
+ def _on_run_commands(self) -> None:
926
+ """Handle the Run Commands button click."""
927
+ m = self._model
928
+ if m.manifest_path is None:
929
+ return
930
+
931
+ self._run_commands_btn.setEnabled(False)
932
+ self._install_btn.setEnabled(False)
933
+ self._close_btn.setEnabled(False)
934
+
935
+ self._status_label.setText('Running post-sync commands\u2026')
936
+
937
+ if not self._log_panel.isVisible():
938
+ self._log_panel.clear()
939
+ self._log_panel.show()
940
+
941
+ self._install_results = None
942
+ self._task = asyncio.create_task(self._run_post_sync_task())
943
+
944
+ async def _run_post_sync_task(self) -> None:
945
+ """Run the post-sync coroutine and route completion/errors."""
946
+ assert self._model.manifest_path is not None
947
+ try:
948
+ results = await run_post_sync(
949
+ self._porringer,
950
+ self._model.manifest_path,
951
+ project_directory=self._model.project_directory,
952
+ callbacks=InstallCallbacks(
953
+ on_action_started=self._on_action_started,
954
+ on_sub_progress=self._on_sub_progress,
955
+ on_progress=self._on_action_progress,
956
+ ),
957
+ plugins=self._discovered_plugins,
958
+ )
959
+ self._on_post_sync_finished(results)
960
+ except asyncio.CancelledError:
961
+ self._on_post_sync_finished(SetupResults(actions=[]))
962
+ except Exception as exc:
963
+ logger.exception('Post-sync execution failed')
964
+ self._on_install_error(f'Post-sync failed: {exc}')
965
+
966
+ def _on_post_sync_finished(self, results: SetupResults) -> None:
967
+ """Handle post-sync completion."""
968
+ from synodic_client.operations.schema import format_install_summary
969
+
970
+ m = self._model
971
+ m.post_sync_completed = True
972
+ m.post_sync_results = list(results.results)
973
+
974
+ self._set_phase(PreviewPhase.DONE)
975
+
976
+ pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0
977
+
978
+ # If we have stashed install results (auto-run path), use combined summary
979
+ install_results = (
980
+ list(self._install_results.results) if hasattr(self, '_install_results') and self._install_results else None
981
+ )
982
+ summary = format_install_summary(
983
+ install_results=install_results,
984
+ post_sync_results=m.post_sync_results,
985
+ pre_skipped_count=pre_skipped,
986
+ )
987
+ self._status_label.setText(summary)
988
+ self._install_btn.setEnabled(False)
989
+ self._run_commands_btn.setEnabled(False)
990
+ self._close_btn.setEnabled(True)
991
+ self.install_finished.emit(results)
992
+
878
993
 
879
994
  # ---------------------------------------------------------------------------
880
995
  # InstallPreviewWindow — standalone URI-based install window